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 (like List or Dict) is allocated. When this exceeds gc_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 Heap arena in crates/monty/src/heap.rs and the pluggable ResourceTracker trait in crates/monty/src/resource.rs.
  • Every allocation passes through Heap::allocate, which calls tracker.on_allocate to verify limits before committing memory.
  • The default LimitedTracker maintains allocation_count and current_memory counters, enforcing max_allocations and max_memory limits.
  • Deallocations are tracked via Heap::dec_ref calling tracker.on_free, ensuring current_memory remains accurate as objects are freed.
  • GC-tracked containers increment allocations_since_gc and set may_have_cycles to 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →