use std::{
thread,
time::{Duration, Instant},
};
use monty::{MontyRepl, MontyRun, RunProgress};
use monty_types::{
CompileOptions, ExcType, MontyException, MontyObject, NameLookupResult, PrintWriter, ResourceLimits,
ResourceTracker,
};
fn resolve_name_lookups(mut progress: RunProgress) -> Result<RunProgress, MontyException> {
while let RunProgress::NameLookup(lookup) = progress {
let name = lookup.name.clone();
progress = lookup.resume(
NameLookupResult::Value(MontyObject::Function { name, docstring: None }),
PrintWriter::Stdout,
)?;
}
Ok(progress)
}
#[test]
#[cfg(feature = "ref-count-return")]
fn gc_collects_dict_cycles_via_has_refs() {
let code = r"
# Create many dict cycles
for i in range(200001):
d1 = {}
d2 = {'ref': d1}
d1['ref'] = d2 # Cycle formed; reassignment next iteration seeds the GC
# Create final result (not a cycle)
result = 'done'
result
";
let ex = MontyRun::new(code.to_owned(), "test.py", vec![], CompileOptions::default()).unwrap();
let output = ex.run_ref_counts(vec![]).expect("should succeed");
assert!(
output.allocations_since_gc < 100_000,
"GC should have run: allocations_since_gc = {}",
output.allocations_since_gc
);
assert!(
output.heap_count < 20,
"GC should collect most unreachable dict cycles: {} heap objects (expected < 20)",
output.heap_count
);
}
#[test]
#[cfg(feature = "ref-count-return")]
fn gc_collects_list_iterator_cycles() {
let code = r"
for i in range(100001):
a = []
iterator = iter(a)
a.append(iterator)
result = [1, 2, 3]
len(result)
";
let ex = MontyRun::new(code.to_owned(), "test.py", vec![], CompileOptions::default()).unwrap();
let output = ex.run_ref_counts(vec![]).expect("should succeed");
assert!(
output.heap_count < 30,
"GC should collect list-iterator cycles: {} heap objects (expected < 30)",
output.heap_count
);
}
#[test]
#[cfg(feature = "ref-count-return")]
fn gc_collects_concrete_iterator_cycles() {
let code = r"
for i in range(100001):
container = []
source = (container,)
iterator = iter(source)
container.append(iterator)
mapping = {}
iterator = iter(mapping)
mapping['iterator'] = iterator
result = [1, 2, 3]
len(result)
";
let ex = MontyRun::new(code.to_owned(), "test.py", vec![], CompileOptions::default()).unwrap();
let output = ex.run_ref_counts(vec![]).expect("should succeed");
assert!(
output.heap_count < 40,
"GC should collect concrete iterator cycles: {} heap objects (expected < 40)",
output.heap_count
);
}
#[test]
#[cfg(feature = "ref-count-return")]
fn gc_collects_iterator_cycles_rooted_by_the_iterator() {
let code = r"
class Src:
def step(self):
return 1
roots = []
for i in range(2000):
o = Src()
it = iter(o.step, 0)
o.it = it
roots.append(it)
a = []
li = iter(a)
a.append(li)
roots.append(li)
roots = None
for i in range(2000):
d = {}
d['self'] = d
result = 'done'
result
";
let ex = MontyRun::new(code.to_owned(), "test.py", vec![], CompileOptions::default()).unwrap();
let tracker = ResourceTracker::new(ResourceLimits::default().gc_interval(500));
let output = ex.run_ref_counts_with_tracker(vec![], tracker).expect("should succeed");
assert!(
output.heap_count < 20,
"GC should collect iterator-rooted cycles: {} heap objects (expected < 20)",
output.heap_count
);
}
#[test]
#[cfg(feature = "ref-count-return")]
fn gc_collects_list_cycles() {
let code = r"
# Create many self-referencing list cycles
for i in range(200001):
a = []
a.append(a) # Creates cycle; reassignment next iteration seeds the GC
# Create final result (not a cycle)
result = [1, 2, 3]
len(result)
";
let ex = MontyRun::new(code.to_owned(), "test.py", vec![], CompileOptions::default()).unwrap();
let output = ex.run_ref_counts(vec![]).expect("should succeed");
assert!(
output.allocations_since_gc < 100_000,
"GC should have run: allocations_since_gc = {}",
output.allocations_since_gc
);
assert!(
output.heap_count < 20,
"GC should collect most unreachable list cycles: {} heap objects (expected < 20)",
output.heap_count
);
assert_eq!(
output.counts.get("a"),
Some(&2),
"self-referencing list should have refcount 2"
);
assert_eq!(
output.counts.get("result"),
Some(&1),
"result list should have refcount 1"
);
}
#[test]
fn time_limit_exceeded() {
let code = r"
x = 0
for i in range(100000000):
x = x + 1
x
";
let ex = MontyRun::new(code.to_owned(), "test.py", vec![], CompileOptions::default()).unwrap();
let limits = ResourceLimits::default().max_duration(Duration::from_millis(50));
let result = ex.run(vec![], ResourceTracker::new(limits), PrintWriter::Stdout);
assert!(result.is_err(), "should exceed time limit");
let exc = result.unwrap_err();
assert_eq!(exc.exc_type(), ExcType::TimeoutError);
assert!(
exc.message().is_some_and(|m| m.contains("time limit exceeded")),
"expected time limit error, got: {exc}"
);
}
#[test]
fn time_limit_not_exceeded() {
let code = "x = 1 + 2\nx";
let ex = MontyRun::new(code.to_owned(), "test.py", vec![], CompileOptions::default()).unwrap();
let limits = ResourceLimits::default().max_duration(Duration::from_secs(5));
let result = ex.run(vec![], ResourceTracker::new(limits), PrintWriter::Stdout);
assert!(result.is_ok(), "should not exceed time limit");
}
#[test]
fn run_without_limits_succeeds() {
let code = r"
result = []
for i in range(100):
result.append(str(i))
len(result)
";
let ex = MontyRun::new(code.to_owned(), "test.py", vec![], CompileOptions::default()).unwrap();
let result = ex.run_no_limits(vec![]);
assert!(result.is_ok(), "standard run should succeed");
}
#[test]
#[cfg(feature = "ref-count-return")]
fn gc_interval_triggers_collection() {
let code = r"
result = 'done'
for i in range(210000):
a = []
a.append(a)
result
";
let ex = MontyRun::new(code.to_owned(), "test.py", vec![], CompileOptions::default()).unwrap();
let output = ex
.run_ref_counts(vec![])
.expect("should succeed with GC enabled on cycles");
assert_eq!(output.py_object, MontyObject::String("done".to_owned()));
assert!(
output.allocations_since_gc < 100_000,
"default GC interval should have triggered collection: allocations_since_gc = {}",
output.allocations_since_gc
);
assert!(
output.heap_count <= 20_000,
"GC should collect most unreachable list cycles: {} heap objects",
output.heap_count
);
}
#[test]
#[cfg(feature = "ref-count-return")]
fn gc_interval_limit_is_respected() {
let code = r"
for i in range(25):
a = []
a.append(a)
result = 'done'
result
";
let ex = MontyRun::new(code.to_owned(), "test.py", vec![], CompileOptions::default()).unwrap();
let limits = ResourceLimits::default().gc_interval(10);
let output = ex
.run_ref_counts_with_tracker(vec![], ResourceTracker::new(limits))
.expect("should succeed with custom GC interval");
assert_eq!(output.py_object, MontyObject::String("done".to_owned()));
assert!(
output.allocations_since_gc < 10,
"configured GC interval should trigger collections before the default; allocations_since_gc = {}",
output.allocations_since_gc
);
assert!(
output.heap_count <= 10,
"GC should collect most unreachable list cycles: {} heap objects",
output.heap_count
);
}
fn assert_timeout_in_builtin(code: &str, label: &str) {
let ex = MontyRun::new(code.to_owned(), "test.py", vec![], CompileOptions::default()).unwrap();
let limits = ResourceLimits::default().max_duration(Duration::from_millis(100));
let start = Instant::now();
let result = ex.run(vec![], ResourceTracker::new(limits), PrintWriter::Stdout);
let elapsed = start.elapsed();
assert!(result.is_err(), "{label}: should exceed time limit");
let exc = result.unwrap_err();
assert_eq!(
exc.exc_type(),
ExcType::TimeoutError,
"{label}: expected TimeoutError, got: {exc}"
);
assert!(
elapsed < Duration::from_secs(2),
"{label}: should terminate promptly, took {elapsed:?}"
);
}
#[test]
fn timeout_in_sum_builtin() {
assert_timeout_in_builtin("sum(range(10**18))", "sum(range(10**18))");
}
#[test]
fn timeout_in_list_constructor() {
assert_timeout_in_builtin("list(range(10**18))", "list(range(10**18))");
}
const BYTES_SEARCH_EXPRS: &[&str] = &[
"needle in haystack",
"haystack.find(needle)",
"haystack.rfind(needle)",
"haystack.count(needle)",
"haystack.split(needle)",
"haystack.rsplit(needle)",
"haystack.replace(needle, b'')",
"haystack.partition(needle)",
"haystack.rpartition(needle)",
];
fn run_bytes_search(expr: &str, haystack: Vec<u8>, needle: Vec<u8>, limits: ResourceLimits) -> BytesSearchOutcome {
let run = MontyRun::new(
expr.to_owned(),
"test.py",
vec!["haystack".to_owned(), "needle".to_owned()],
CompileOptions::default(),
)
.unwrap();
let start = Instant::now();
let result = run.run(
vec![MontyObject::Bytes(haystack), MontyObject::Bytes(needle)],
ResourceTracker::new(limits),
PrintWriter::Stdout,
);
BytesSearchOutcome {
elapsed: start.elapsed(),
result,
}
}
struct BytesSearchOutcome {
elapsed: Duration,
result: Result<MontyObject, MontyException>,
}
fn near_match_inputs(haystack_len: usize, needle_len: usize) -> (Vec<u8>, Vec<u8>) {
let mut needle = vec![b'a'; needle_len];
*needle.last_mut().unwrap() = b'b';
(vec![b'a'; haystack_len], needle)
}
#[test]
fn bytes_search_is_not_quadratic() {
for expr in BYTES_SEARCH_EXPRS {
let (haystack, needle) = near_match_inputs(1_000_000, 50_000);
let outcome = run_bytes_search(expr, haystack, needle, ResourceLimits::default());
assert!(
outcome.result.is_ok(),
"{expr}: expected success, got {:?}",
outcome.result
);
assert!(
outcome.elapsed < Duration::from_millis(300),
"{expr}: took {:?}, expected a linear scan",
outcome.elapsed
);
}
}
#[test]
fn timeout_in_bytes_search() {
for expr in BYTES_SEARCH_EXPRS {
let (haystack, needle) = near_match_inputs(64 * 1024 * 1024, 4096);
let limits = ResourceLimits::default().max_duration(Duration::from_millis(1));
let outcome = run_bytes_search(expr, haystack, needle, limits);
let exc = outcome
.result
.expect_err(&format!("{expr}: expected the time limit to fire"));
assert_eq!(exc.exc_type(), ExcType::TimeoutError, "{expr}");
assert!(
outcome.elapsed < Duration::from_secs(2),
"{expr}: should terminate promptly, took {:?}",
outcome.elapsed
);
}
}
#[test]
fn timeout_in_bounded_deque_repeat() {
assert_timeout_in_builtin(
"from collections import deque\ndeque([[1]], maxlen=10**9) * 10**9",
"deque(maxlen=10**9) * 10**9",
);
}
#[test]
fn timeout_in_sorted_builtin() {
assert_timeout_in_builtin("sorted(range(10**18))", "sorted(range(10**18))");
}
#[test]
fn timeout_in_min_builtin() {
assert_timeout_in_builtin("min(range(10**18))", "min(range(10**18))");
}
#[test]
fn timeout_in_max_builtin() {
assert_timeout_in_builtin("max(range(10**18))", "max(range(10**18))");
}
#[test]
fn timeout_in_all_builtin() {
assert_timeout_in_builtin("all(range(1, 10**18))", "all(range(1, 10**18))");
}
#[test]
fn timeout_in_any_builtin() {
assert_timeout_in_builtin("list(enumerate(range(10**18)))", "enumerate(range(10**18))");
}
#[test]
fn timeout_in_tuple_constructor() {
assert_timeout_in_builtin("tuple(range(10**18))", "tuple(range(10**18))");
}
#[test]
fn timeout_in_str_join() {
assert_timeout_in_builtin("' '.join(str(i) for i in range(10**18))", "str.join with generator");
}
#[test]
fn timeout_in_sorted_comparison_loop() {
let code = r"
x = list(range(10**6, 0, -1))
sorted(x)
";
assert_timeout_in_builtin(code, "sorted(reversed list)");
}
#[test]
fn timeout_in_list_repetition() {
assert_timeout_in_builtin("[1, 2, 3] * 10_000_000", "list repetition");
}
#[test]
fn timeout_in_tuple_repetition() {
assert_timeout_in_builtin("(1, 2, 3) * 10_000_000", "tuple repetition");
}
#[test]
fn timeout_in_list_equality() {
let code = r"
a = list(range(10_000_000))
b = list(range(10_000_000))
a == b
";
assert_timeout_in_builtin(code, "list equality");
}
#[test]
fn timeout_in_dict_equality() {
let code = r"
a = {i: i for i in range(10_000_000)}
b = {i: i for i in range(10_000_000)}
a == b
";
assert_timeout_in_builtin(code, "dict equality");
}
#[test]
fn timeout_in_str_splitlines() {
let code = r"
s = 'a\n' * 5_000_000
s.splitlines()
";
assert_timeout_in_builtin(code, "str.splitlines()");
}
#[test]
fn timeout_in_bytes_splitlines() {
let code = r"
s = b'a\n' * 5_000_000
s.splitlines()
";
assert_timeout_in_builtin(code, "bytes.splitlines()");
}
#[test]
fn suspension_time_does_not_count_toward_max_duration() {
let code = "interrupt()\nsum(range(100))";
let run = MontyRun::new(code.to_owned(), "test.py", vec![], CompileOptions::default()).unwrap();
let limits = ResourceLimits::default().max_duration(Duration::from_millis(100));
let progress = run
.start(vec![], ResourceTracker::new(limits), PrintWriter::Stdout)
.unwrap();
let call = resolve_name_lookups(progress)
.unwrap()
.into_function_call()
.expect("interrupt call");
thread::sleep(Duration::from_millis(300));
let progress = call.resume(MontyObject::None, PrintWriter::Stdout).unwrap();
let RunProgress::Complete(value) = progress else {
panic!("expected Complete, got another suspension");
};
assert_eq!(value, MontyObject::Int(4950));
}
#[test]
fn call_function_enforces_max_duration() {
let limits = ResourceLimits::default().max_duration(Duration::from_millis(50));
let mut repl = MontyRepl::new("test.py", ResourceTracker::new(limits), CompileOptions::default());
repl.feed_run(
"def spin():\n while True:\n pass",
vec![],
PrintWriter::Stdout,
)
.unwrap();
let exc = repl
.call_function("spin", vec![], PrintWriter::Stdout)
.expect_err("infinite loop must hit the time limit");
assert_eq!(exc.exc_type(), ExcType::TimeoutError);
}
fn assert_repr_timeout(code: &str, label: &str) {
let run = MontyRun::new(code.to_owned(), "test.py", vec![], CompileOptions::default()).unwrap();
let limits = ResourceLimits::default();
let progress = run
.start(vec![], ResourceTracker::new(limits), PrintWriter::Stdout)
.unwrap();
let mut call = resolve_name_lookups(progress)
.unwrap()
.into_function_call()
.expect("interrupt call");
assert_eq!(call.function_name, "interrupt");
call.tracker_mut().set_max_duration(Duration::from_millis(10));
let start = Instant::now();
let result = call.resume(MontyObject::None, PrintWriter::Stdout);
let elapsed = start.elapsed();
let exc = result.unwrap_err();
assert_eq!(
exc.exc_type(),
ExcType::TimeoutError,
"{label}: expected TimeoutError, got: {exc}"
);
let msg = exc.message().unwrap();
assert!(msg.starts_with("time limit exceeded:"));
assert!(msg.ends_with("ms > 10ms"));
assert!(
elapsed < Duration::from_millis(200),
"{label}: should terminate promptly, took {elapsed:?}"
);
}
#[test]
fn timeout_truncation_in_list_repr() {
let code = r"
x = ['abcdefghij'] * 100_000
interrupt()
repr(x)
";
assert_repr_timeout(code, "list repr");
}
#[test]
fn timeout_truncation_in_dict_repr() {
let code = r"
x = {i: 'abcdefghij' for i in range(100_000)}
interrupt()
repr(x)
";
assert_repr_timeout(code, "dict repr");
}
#[test]
fn timeout_truncation_in_set_repr() {
let code = r"
x = {str(i) for i in range(100_000)}
interrupt()
repr(x)
";
assert_repr_timeout(code, "set repr");
}
#[test]
fn re_sub_backtracking_limit_raises_pattern_error() {
let code = r"
import re
re.sub('(a+)+\\1b', 'X', 'a' * 30 + 'c')
";
let ex = MontyRun::new(code.to_owned(), "test.py", vec![], CompileOptions::default()).unwrap();
let result = ex.run_no_limits(vec![]);
assert!(result.is_err(), "backtracking limit should raise an error");
let exc = result.unwrap_err();
assert_eq!(exc.exc_type(), ExcType::RePatternError);
assert!(
exc.message().is_some_and(|m| m.contains("backtrack")),
"expected backtracking error, got: {exc}"
);
}
#[test]
fn nested_itertools_adaptors_are_bounded_by_the_recursion_limit() {
for wrap in [
"itertools.islice(source, 0, None)",
"itertools.chain(source)",
"itertools.pairwise(source)",
"itertools.compress(source, itertools.repeat(1))",
"itertools.cycle(source)",
] {
let code = format!(
r"
import itertools
source = iter([1, 2, 3])
for _ in range(100):
source = {wrap}
next(source)
"
);
let ex = MontyRun::new(code, "test.py", vec![], CompileOptions::default()).unwrap();
let limits = ResourceLimits::default().max_recursion_depth(10);
let result = ex.run(vec![], ResourceTracker::new(limits), PrintWriter::Stdout);
let exc = result.expect_err("nested adaptors should exceed the recursion limit");
assert_eq!(exc.exc_type(), ExcType::RecursionError, "wrapper: {wrap}");
}
}
#[test]
fn nested_itertools_adaptors_below_the_recursion_limit_iterate() {
let code = r"
import itertools
source = iter([1, 2, 3])
for _ in range(150):
source = itertools.islice(source, 0, None)
list(source)
";
let ex = MontyRun::new(code.to_owned(), "test.py", vec![], CompileOptions::default()).unwrap();
let limits = ResourceLimits::default().max_recursion_depth(200);
let result = ex.run(vec![], ResourceTracker::new(limits), PrintWriter::Stdout);
let list = result.expect("nesting below the recursion limit should succeed");
assert_eq!(
list,
MontyObject::List(vec![MontyObject::Int(1), MontyObject::Int(2), MontyObject::Int(3)])
);
}
#[test]
fn nested_namedtuple_ordering_is_bounded_by_the_recursion_limit() {
for build in [
"a = NT(0)\nb = NT(0)\nfor _ in range(100):\n a = NT(a)\n b = NT(b)",
"a = NT(0)\nb = (0,)\nfor _ in range(100):\n a = NT(a)\n b = (b,)",
] {
let code = format!(
r"
from collections import namedtuple
NT = namedtuple('NT', ['x'])
{build}
a < b
"
);
let ex = MontyRun::new(code, "test.py", vec![], CompileOptions::default()).unwrap();
let limits = ResourceLimits::default().max_recursion_depth(10);
let result = ex.run(vec![], ResourceTracker::new(limits), PrintWriter::Stdout);
let exc = result.expect_err("nested namedtuple ordering should exceed the recursion limit");
assert_eq!(exc.exc_type(), ExcType::RecursionError, "build: {build}");
}
}