How Monty Tracks Memory Allocations in Python Objects
Monty tracks memory allocations through a cooperative system where the Heap arena forwards object sizes to a pluggable ResourceTracker trait that records statistics and enforces limits before any allocation succeeds.
Monty is a secure Python interpreter built by Pydantic designed to execute untrusted code safely. Understanding how Monty tracks memory allocations is essential for configuring resource limits and debugging memory usage in sandboxed environments. The system relies on precise accounting at the moment of allocation and deallocation to prevent memory exhaustion attacks.
The Core Architecture: Heap and ResourceTracker
Monty’s memory tracking operates through two primary components defined in crates/monty/src/heap.rs and crates/monty/src/resource.rs:
Heap<T>: The arena that stores every heap-allocated Python object. It manages object slots, reference counting, and garbage collection flags.ResourceTracker: A trait that defines hooks for allocation events, allowing pluggable policies for statistics gathering and limit enforcement.
When the VM creates a new object, it calls Heap::allocate, which immediately consults the attached tracker before committing memory.
How Allocation Tracking Works
The Allocation Flow in Heap::allocate
The allocate method in crates/monty/src/heap.rs serves as the central checkpoint for all heap objects. Before storing any value, it invokes on_allocate to verify that the operation complies with resource limits:
pub fn allocate(&mut self, data: HeapData) -> Result<HeapId, ResourceError> {
// 1. Ask the tracker how many bytes this allocation will need.
self.tracker.on_allocate(|| data.py_estimate_size())?;
// 2. For GC‑tracked containers, bump the allocation counter and
// mark that a reference cycle may exist.
if data.is_gc_tracked() {
self.allocations_since_gc = self.allocations_since_gc.wrapping_add(1);
if data.has_refs() {
self.may_have_cycles = true;
}
}
// 3. Store the value (or reuse a freed slot) and return its `HeapId`.
// ... implementation details ...
}
The closure passed to on_allocate calculates the object size lazily, ensuring the tracker only computes the value when necessary.
Counting Allocations with LimitedTracker
The default concrete implementation, LimitedTracker in crates/monty/src/resource.rs, maintains two critical counters: allocation_count and current_memory. Its on_allocate method validates limits before incrementing these counters:
fn on_allocate(&mut self, get_size: impl FnOnce() -> usize) -> Result<(), ResourceError> {
if let Some(max) = self.limits.max_allocations && self.allocation_count >= max {
return Err(ResourceError::Allocation { limit: max, count: self.allocation_count + 1 });
}
let size = get_size();
if let Some(max) = self.limits.max_memory {
let new_memory = self.current_memory + size;
if new_memory > max {
return Err(ResourceError::Memory { limit: max, used: new_memory });
}
}
self.allocation_count += 1;
self.current_memory += size;
Ok(())
}
If either limit is exceeded, the method returns a ResourceError immediately, causing Heap::allocate to fail before any memory is committed.
Deallocation and Memory Accounting
Monty tracks deallocations through reference counting. When an object's reference count drops to zero, Heap::dec_ref in crates/monty/src/heap.rs reclaims the slot and notifies the tracker:
if entry.refcount > 1 {
entry.refcount -= 1;
} else if let Some(value) = slot.take() {
self.free_list.push(id);
if let Some(ref data) = value.data {
self.tracker.on_free(|| data.py_estimate_size());
}
// Recursively dec‑ref child objects …
}
The on_free hook allows LimitedTracker to decrement current_memory and maintain accurate statistics. Notably, allocation_count is not decremented on free; it represents the cumulative number of allocations made during execution, not the current live count.
Garbage Collection Integration
Monty uses a generational garbage collector to handle reference cycles. The Heap tracks GC-related metadata during allocation:
allocations_since_gc: A counter incremented whenever a GC-tracked container (likeListorDict) is allocated. When this exceedsgc_interval, the VM triggers a collection cycle.may_have_cycles: A boolean flag set when a newly allocated container contains references to other objects. This signals the GC that a cycle might exist.
These mechanisms ensure that memory tracking remains accurate even when objects form cycles that prevent immediate deallocation through reference counting alone.
Enforcing Resource Limits
Beyond simple allocation counting, the ResourceTracker trait provides hooks for additional safety checks:
check_time: Called periodically during VM execution to enforce timeout limits.check_recursion_depth: Validates that the call stack has not exceeded a configured maximum depth.check_large_result: Preemptively denies operations that would produce results larger than 100 KB, preventing memory bombs from large string or list concatenations.
These checks are lightweight and strategically placed at interpreter entry points to minimize performance overhead while maintaining strict resource boundaries.
Practical Example
The following example demonstrates how to configure a Heap with strict allocation and memory limits using LimitedTracker:
use monty::resource::{LimitedTracker, ResourceLimits};
use monty::heap::Heap;
use monty::value::Value;
// 1️⃣ Create a tracker that caps allocations at 10 000 objects and memory at 2 MiB.
let limits = ResourceLimits::default()
.max_allocations(10_000)
.max_memory(2 * 1024 * 1024);
let tracker = LimitedTracker::new(limits);
// 2️⃣ Build a heap that uses this tracker.
let mut heap = Heap::new(0, tracker);
// 3️⃣ Allocate a Python string – the tracker records the allocation.
let hello_id = heap.allocate(monty::types::Str::new("hello".into()))?;
assert_eq!(heap.tracker().allocation_count(), 1);
// 4️⃣ Release the string – the tracker’s `on_free` updates memory usage.
heap.dec_ref(hello_id);
assert_eq!(heap.tracker().allocation_count(), 1); // count stays, memory drops
// 5️⃣ Trigger a limit violation.
let huge = "x".repeat(5_000_000); // > 5 MiB
match heap.allocate(monty::types::Str::new(huge.into())) {
Err(e) => println!("allocation failed: {}", e), // prints MemoryError
Ok(_) => {}
}
This example illustrates how Monty tracks memory allocations at the granularity of individual Python objects, enabling precise resource control and safe execution of untrusted code.
Summary
- Monty tracks memory allocations through a cooperation between the
Heaparena incrates/monty/src/heap.rsand the pluggableResourceTrackertrait incrates/monty/src/resource.rs. - Every allocation passes through
Heap::allocate, which callstracker.on_allocateto verify limits before committing memory. - The default
LimitedTrackermaintainsallocation_countandcurrent_memorycounters, enforcingmax_allocationsandmax_memorylimits. - Deallocations are tracked via
Heap::dec_refcallingtracker.on_free, ensuringcurrent_memoryremains accurate as objects are freed. - GC-tracked containers increment
allocations_since_gcand setmay_have_cyclesto coordinate garbage collection with memory tracking.
Frequently Asked Questions
How does Monty prevent memory exhaustion from untrusted code?
Monty prevents memory exhaustion by checking max_memory and max_allocations limits inside LimitedTracker::on_allocate before any heap memory is committed. If the new allocation would exceed the configured threshold, the method returns ResourceError::Memory or ResourceError::Allocation, causing the VM to raise a MemoryError in the Python environment without actually consuming the resources.
What is the difference between allocation_count and current_memory in Monty?
allocation_count tracks the cumulative number of successful allocation operations since the heap was created, while current_memory tracks the total bytes currently allocated on the heap. When an object is freed via dec_ref, current_memory decreases by the object's size, but allocation_count remains unchanged because it represents a historical statistic rather than a live counter.
How does Monty track memory for Python objects that contain references to other objects?
When allocating GC-tracked containers like List or Dict, Heap::allocate checks data.is_gc_tracked() and increments allocations_since_gc. If the container contains references (data.has_refs()), it sets may_have_cycles = true. While the memory tracking for the container itself is handled via py_estimate_size() during allocation, the reference tracking ensures the garbage collector can break cycles when objects become unreachable, allowing dec_ref to eventually reclaim the memory and call on_free.
Can Monty enforce limits on execution time and recursion depth as well as memory?
Yes, the ResourceTracker trait provides additional hooks beyond memory tracking. check_time enforces timeout limits by returning an error if the execution time exceeds a threshold, while check_recursion_depth validates that the call stack has not exceeded max_recursion_depth. These checks are invoked at strategic points in the interpreter, such as before function calls or during VM instruction loops, ensuring comprehensive resource control alongside memory allocation tracking.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →