Skip to main content

monty_types/
resource.rs

1//! Resource limits: the [`ResourceTracker`] trait the interpreter heap/VM
2//! are generic over, plus the stock [`NoLimitTracker`]/[`LimitedTracker`]
3//! implementations and their [`ResourceLimits`] configuration.
4
5#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
6use std::time::Instant;
7use std::{cell::Cell, error::Error, fmt, time::Duration};
8
9// `std::time::Instant::now()` panics ("time not implemented on this platform")
10// on `wasm32-unknown-unknown`, so any `max_duration` limit aborts there. Swap in
11// `web_time::Instant` (a `performance.now()`-backed drop-in) only for that
12// target; every other target (native, WASI) keeps std, so the `web-time`
13// dependency is pulled in only where it's needed (see Cargo.toml).
14#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
15use web_time::Instant;
16
17/// Threshold in bytes above which `check_large_result` is called.
18///
19/// Operations that may produce results larger than this threshold (100KB) should call
20/// `check_large_result` before performing the operation. This prevents DoS attacks
21/// where operations like `2 ** 10_000_000` allocate huge amounts of memory before
22/// the memory check can catch them.
23pub const LARGE_RESULT_THRESHOLD: usize = 100_000;
24/// Error returned when a resource limit is exceeded during execution.
25///
26/// This allows the sandbox to enforce strict limits on execution time
27/// and memory usage.
28///
29/// All variants except `Recursion` are **uncatchable** inside the sandbox:
30/// untrusted code must never intercept resource enforcement. `Recursion`
31/// surfaces as a catchable `RecursionError`, matching CPython.
32#[derive(Debug, Clone)]
33pub enum ResourceError {
34    /// Maximum execution time exceeded.
35    Time { limit: Duration, elapsed: Duration },
36    /// Maximum memory usage exceeded.
37    Memory { limit: usize, used: usize },
38    /// Maximum recursion depth exceeded.
39    Recursion { limit: usize, depth: usize },
40}
41
42impl fmt::Display for ResourceError {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        match self {
45            Self::Time { limit, elapsed } => {
46                write!(f, "time limit exceeded: {elapsed:?} > {limit:?}")
47            }
48            Self::Memory { limit, used } => {
49                write!(f, "memory limit exceeded: {used} bytes > {limit} bytes")
50            }
51            Self::Recursion { .. } => {
52                write!(f, "maximum recursion depth exceeded")
53            }
54        }
55    }
56}
57
58impl Error for ResourceError {}
59/// Trait for tracking resource usage and scheduling garbage collection.
60///
61/// Implementations can enforce limits on time and memory, as well as
62/// schedule periodic garbage collection.
63///
64/// All implementations should eventually trigger garbage collection to handle
65/// reference cycles. [`gc_interval`](Self::gc_interval) controls *frequency*,
66/// not whether GC runs at all.
67pub trait ResourceTracker: fmt::Debug {
68    /// Called when memory is freed (during dec_ref or garbage collection).
69    ///
70    /// # Arguments
71    /// * `size` - Size in bytes of the freed allocation
72    fn on_free(&self, get_size: impl FnOnce() -> usize);
73
74    /// Called periodically (at statement boundaries) to check time limits.
75    ///
76    /// Returns `Ok(())` if within time limit, or `Err(ResourceError::Time)`
77    /// if the limit is exceeded.
78    ///
79    /// Takes `&self` rather than `&mut self` because checking elapsed time is a
80    /// read-only operation. This allows time checks in contexts that only have
81    /// an immutable heap reference, such as `py_repr_fmt`.
82    fn check_time(&self) -> Result<(), ResourceError>;
83
84    /// Called before pushing a new call frame to check recursion depth.
85    ///
86    /// Returns `Ok(())` if within recursion limit, or `Err(ResourceError::Recursion)`
87    /// if the limit would be exceeded.
88    ///
89    /// # Arguments
90    /// * `current_depth` - Current call stack depth (before the new frame is pushed)
91    fn check_recursion_depth(&self, current_depth: usize) -> Result<(), ResourceError>;
92
93    /// Called before operations that may produce large results (>100KB).
94    ///
95    /// This allows pre-emptive rejection of operations like `2 ** 10_000_000`
96    /// before the memory is actually allocated. The check only happens for
97    /// estimated result sizes above `LARGE_RESULT_THRESHOLD` to avoid overhead
98    /// on small operations.
99    ///
100    /// # Arguments
101    /// * `estimated_bytes` - Approximate size of the result in bytes
102    ///
103    /// Returns `Ok(())` to allow the operation, or `Err(ResourceError)` to reject.
104    fn check_large_result(&self, estimated_bytes: usize) -> Result<(), ResourceError>;
105
106    /// Called before tracked memory grows: a new heap allocation, in-place
107    /// container growth (`list.append`, `dict[k] = v`), or a `StringBuilder`
108    /// reservation.
109    ///
110    /// Returns `Ok(())` if the growth should proceed, or `Err(ResourceError)`
111    /// if a limit would be exceeded. Balanced by [`on_free`](Self::on_free):
112    /// entry release reads `py_estimate_size()`, which includes in-place growth.
113    ///
114    /// # Arguments
115    /// * `get_additional` - Lazily computes the approximate growth in bytes;
116    ///   implementations that ignore size (`NoLimitTracker`, or `LimitedTracker`
117    ///   with no memory limit) never pay for it
118    fn on_grow(&self, get_additional: impl FnOnce() -> usize) -> Result<(), ResourceError>;
119
120    /// Returns the configured garbage collection interval, in GC-tracked
121    /// allocations.
122    ///
123    /// The cycle collector runs at most once per `gc_interval` GC-tracked
124    /// allocations, and additionally short-circuits when no cycle candidates
125    /// are pending — so programs that never form cycles pay no collector
126    /// cost regardless of their allocation rate.
127    ///
128    /// Implementations that do not expose a configurable GC interval should
129    /// return `None`, which tells the heap to use its built-in default
130    /// scheduling threshold.
131    fn gc_interval(&self) -> Option<usize>;
132
133    /// Called when the VM enters its execution loop from a host boundary
134    /// (`VM::run_external`), starting one execution window.
135    ///
136    /// Paired with [`on_execution_stop`](Self::on_execution_stop) and never
137    /// nested — VM-internal re-entry (task switches, host-initiated function
138    /// evaluation) uses the raw run loop, so its time falls inside the
139    /// enclosing window. Trackers that measure execution time run their
140    /// clock between the pair; the clock is *not* running while execution is
141    /// suspended waiting on the host (external function calls) or between
142    /// feeds. Default is a no-op.
143    fn on_execution_start(&self) {}
144
145    /// Called when the VM leaves its execution loop — on completion, error,
146    /// or suspension at an external call. See [`on_execution_start`](Self::on_execution_start).
147    fn on_execution_stop(&self) {}
148
149    /// Lowers the active recursion-depth limit to `new_limit`.
150    ///
151    /// Exposed under the `test-hooks` feature so `sys.setrecursionlimit` can
152    /// tighten the depth ceiling from inside fixture code. Implementations
153    /// MUST refuse to *raise* the limit above whatever ceiling the host
154    /// configured at construction time — that would let sandboxed code
155    /// escape the host-imposed safety bound.
156    ///
157    /// Returns `Ok(())` when the requested limit was applied (including the
158    /// no-op case `new_limit == current`). Returns `Err(current)` when the
159    /// request would raise the limit, where `current` is the active limit
160    /// (or `None` if the tracker has no settable limit at all). Callers
161    /// surface this as a `ValueError` in the Python layer.
162    ///
163    /// The default implementation rejects all requests, so wrapper trackers
164    /// that should expose this capability must explicitly delegate to their
165    /// inner tracker.
166    #[cfg(feature = "test-hooks")]
167    fn lower_recursion_limit(&self, _new_limit: usize) -> Result<(), Option<usize>> {
168        Err(None)
169    }
170}
171
172/// A resource tracker that imposes no limits except default recursion limit.
173///
174/// Recursion limit is set to the cpython default of 1000.
175#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
176pub struct NoLimitTracker;
177
178impl ResourceTracker for NoLimitTracker {
179    #[inline]
180    fn on_free(&self, _: impl FnOnce() -> usize) {}
181
182    #[inline]
183    fn check_time(&self) -> Result<(), ResourceError> {
184        Ok(())
185    }
186
187    #[inline]
188    fn on_grow(&self, _: impl FnOnce() -> usize) -> Result<(), ResourceError> {
189        Ok(())
190    }
191
192    /// Set the recursion limit to 1000.
193    ///
194    /// The high limit here may cause stack overflow errors in debug mode, but do not those errors should
195    /// not occur with release builds.
196    #[inline]
197    fn check_recursion_depth(&self, current_depth: usize) -> Result<(), ResourceError> {
198        const DEFAULT_RECURSION_LIMIT: usize = 1000;
199        if current_depth >= DEFAULT_RECURSION_LIMIT {
200            Err(ResourceError::Recursion {
201                limit: DEFAULT_RECURSION_LIMIT,
202                depth: current_depth + 1,
203            })
204        } else {
205            Ok(())
206        }
207    }
208
209    #[inline]
210    fn check_large_result(&self, _estimated_bytes: usize) -> Result<(), ResourceError> {
211        // No limit - always allow operations regardless of result size
212        Ok(())
213    }
214
215    #[inline]
216    fn gc_interval(&self) -> Option<usize> {
217        None
218    }
219}
220
221/// Configuration for resource limits.
222///
223/// All limits are optional - set to `None` to disable a specific limit.
224/// Use `ResourceLimits::default()` for no limits, or build custom limits
225/// with the builder pattern.
226#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
227pub struct ResourceLimits {
228    /// Maximum execution time.
229    pub max_duration: Option<Duration>,
230    /// Maximum heap memory in bytes (approximate).
231    pub max_memory: Option<usize>,
232    /// Run garbage collection every N GC-tracked allocations.
233    pub gc_interval: Option<usize>,
234    /// Maximum recursion depth (function call stack depth).
235    pub max_recursion_depth: Option<usize>,
236}
237
238/// Recommended maximum recursion depth if not otherwise specified.
239pub const DEFAULT_MAX_RECURSION_DEPTH: usize = 1000;
240
241impl ResourceLimits {
242    /// Creates a new ResourceLimits with all limits disabled, except max recursion which is set to 1000.
243    #[must_use]
244    pub fn new() -> Self {
245        Self {
246            max_recursion_depth: Some(1000),
247            ..Default::default()
248        }
249    }
250
251    /// Sets the maximum execution duration.
252    #[must_use]
253    pub fn max_duration(mut self, limit: Duration) -> Self {
254        self.max_duration = Some(limit);
255        self
256    }
257
258    /// Sets the maximum memory usage in bytes.
259    #[must_use]
260    pub fn max_memory(mut self, limit: usize) -> Self {
261        self.max_memory = Some(limit);
262        self
263    }
264
265    /// Sets the garbage collection interval (run GC every N GC-tracked allocations).
266    #[must_use]
267    pub fn gc_interval(mut self, interval: usize) -> Self {
268        self.gc_interval = Some(interval);
269        self
270    }
271
272    /// Sets the maximum recursion depth (function call stack depth).
273    #[must_use]
274    pub fn max_recursion_depth(mut self, limit: Option<usize>) -> Self {
275        self.max_recursion_depth = limit;
276        self
277    }
278}
279
280/// How often to actually check `Instant::elapsed()` in `check_time`.
281///
282/// Calling `Instant::elapsed()` on every `check_time` invocation adds measurable
283/// overhead in tight loops (the VM calls `check_time` on every instruction).
284/// By only checking every N calls, we reduce this overhead while still catching
285/// timeouts promptly.
286const TIME_CHECK_INTERVAL: u16 = 10;
287
288/// A resource tracker that enforces configurable limits.
289///
290/// Tracks memory usage and execution time, returning errors when limits
291/// are exceeded. Also schedules garbage collection at configurable
292/// intervals.
293///
294/// Uses `Cell` for interior mutability to allow many methods which take
295/// `&self` (enabling `&self` on critical methods such as `Heap::allocate`).
296///
297/// `max_duration` limits *cumulative execution time*: the clock runs only
298/// while the VM is executing bytecode (between the outermost
299/// `on_execution_start`/`on_execution_stop` pair) and is paused while
300/// execution is suspended waiting on the host — external function calls,
301/// OS callbacks — and between REPL feeds. The accumulated time is
302/// serialized, so a deserialized session resumes its budget where it left
303/// off rather than restarting from zero.
304#[derive(Debug, serde::Serialize, serde::Deserialize)]
305pub struct LimitedTracker {
306    limits: ResourceLimits,
307    /// Execution time accumulated by completed `on_execution_start`/`stop`
308    /// windows. Serialized so time budgets survive dump/load. The serde
309    /// default helps self-describing formats; postcard snapshots are
310    /// positional, so older snapshot layouts still fail closed at decode.
311    #[serde(default)]
312    total_execution_time: Cell<Duration>,
313    /// When the current execution window started; `None` while suspended or
314    /// idle. Never serialized — a snapshot is by definition taken while not
315    /// executing.
316    #[serde(skip)]
317    running_since: Cell<Option<Instant>>,
318    /// Current approximate memory usage in bytes.
319    current_memory: Cell<usize>,
320    /// Counter for rate-limiting `Instant::elapsed()` calls in `check_time`.
321    check_counter: Cell<u16>,
322    /// Optional override applied on top of `limits.max_recursion_depth`.
323    ///
324    /// `None` (the default — also the value any pre-`test-hooks` snapshot
325    /// deserializes to) means "no override, use the configured ceiling".
326    /// `Some(N)` means "use `N` as the live recursion ceiling instead", and
327    /// is only ever populated by
328    /// [`lower_recursion_limit`](ResourceTracker::lower_recursion_limit)
329    /// under the `test-hooks` feature — `sys.setrecursionlimit` uses it to
330    /// tighten the bound from Python code without escaping the
331    /// host-configured ceiling.
332    ///
333    /// Modeled as an override rather than the live limit so adding this
334    /// field doesn't break deserialization of snapshots produced before it
335    /// existed (`#[serde(default)]` gives back the `None` fallback case).
336    #[serde(default)]
337    recursion_limit_override: Cell<Option<usize>>,
338}
339
340impl LimitedTracker {
341    /// Creates a new LimitedTracker with the given limits.
342    ///
343    /// The execution-time clock starts at zero and only runs while the VM
344    /// executes, so the tracker can be created any amount of time before
345    /// the first run without consuming the duration budget.
346    #[must_use]
347    pub fn new(limits: ResourceLimits) -> Self {
348        Self {
349            limits,
350            total_execution_time: Cell::new(Duration::ZERO),
351            running_since: Cell::new(None),
352            current_memory: Cell::new(0),
353            check_counter: Cell::new(0),
354            recursion_limit_override: Cell::new(None),
355        }
356    }
357
358    /// Returns the live recursion ceiling: the override if one is in effect,
359    /// otherwise the configured `max_recursion_depth`.
360    fn active_recursion_limit(&self) -> Option<usize> {
361        self.recursion_limit_override.get().or(self.limits.max_recursion_depth)
362    }
363
364    /// Returns the current approximate memory usage.
365    ///
366    /// Only meaningful when a `max_memory` limit is configured — without one
367    /// the tracker skips memory accounting entirely and this stays 0.
368    #[must_use]
369    pub fn current_memory(&self) -> usize {
370        self.current_memory.get()
371    }
372
373    /// Returns the cumulative execution time: bytecode-execution wall time
374    /// accumulated across runs/feeds, excluding time suspended on the host
375    /// or idle between feeds. Includes the in-progress window if the VM is
376    /// currently executing.
377    #[must_use]
378    pub fn elapsed(&self) -> Duration {
379        let running = self.running_since.get().map_or(Duration::ZERO, |t| t.elapsed());
380        self.total_execution_time.get() + running
381    }
382
383    /// Returns the configured maximum cumulative execution time, if any.
384    #[must_use]
385    pub fn max_duration(&self) -> Option<Duration> {
386        self.limits.max_duration
387    }
388
389    /// Sets the maximum execution duration as a fresh budget from now,
390    /// resetting the accumulated execution time to zero.
391    ///
392    /// This lets a host enforce a different (typically shorter) time limit
393    /// for a resumed phase — e.g. allowing a long build phase, then giving
394    /// `repr()` of the result only a few milliseconds. Time spent suspended
395    /// in the host never counts toward the budget either way.
396    pub fn set_max_duration(&mut self, duration: Duration) {
397        self.limits.max_duration = Some(duration);
398        self.total_execution_time.set(Duration::ZERO);
399    }
400}
401
402impl ResourceTracker for LimitedTracker {
403    fn on_free(&self, get_size: impl FnOnce() -> usize) {
404        // Memory is only tracked when a limit is configured (`on_grow` skips
405        // the size computation otherwise), so skip symmetrically here.
406        if self.limits.max_memory.is_some() {
407            let current = self.current_memory.get();
408            self.current_memory.set(current.saturating_sub(get_size()));
409        }
410    }
411
412    fn on_grow(&self, get_additional: impl FnOnce() -> usize) -> Result<(), ResourceError> {
413        if let Some(max) = self.limits.max_memory {
414            // Saturating: a wrapping add on 32-bit targets must not slip past
415            // the limit.
416            let new_memory = self.current_memory.get().saturating_add(get_additional());
417            if new_memory > max {
418                return Err(ResourceError::Memory {
419                    limit: max,
420                    used: new_memory,
421                });
422            }
423            self.current_memory.set(new_memory);
424        }
425        // No memory limit: skip the check AND the (possibly costly) size
426        // computation — `get_additional` is never called.
427        Ok(())
428    }
429
430    fn check_time(&self) -> Result<(), ResourceError> {
431        if let Some(max) = self.limits.max_duration {
432            self.check_counter.update(|c| c.wrapping_add(1));
433            if self.check_counter.get().is_multiple_of(TIME_CHECK_INTERVAL) {
434                // Only call Instant::elapsed() every TIME_CHECK_INTERVAL calls
435                let elapsed = self.elapsed();
436                if elapsed > max {
437                    // Reset counter so the very next check_time call also triggers
438                    // an elapsed check. This is important because some callers
439                    // (e.g. repr_sequence_fmt) catch the error and return normally,
440                    // and we need the VM loop's next check_time to re-detect timeout.
441                    self.check_counter.set(TIME_CHECK_INTERVAL.wrapping_sub(1));
442                    return Err(ResourceError::Time { limit: max, elapsed });
443                }
444            }
445        }
446        Ok(())
447    }
448
449    fn check_recursion_depth(&self, current_depth: usize) -> Result<(), ResourceError> {
450        if let Some(max) = self.active_recursion_limit() {
451            // current_depth is before push, so new depth would be current_depth + 1
452            if current_depth >= max {
453                return Err(ResourceError::Recursion {
454                    limit: max,
455                    depth: current_depth + 1,
456                });
457            }
458        }
459        Ok(())
460    }
461
462    fn check_large_result(&self, estimated_bytes: usize) -> Result<(), ResourceError> {
463        if let Some(max) = self.limits.max_memory {
464            let new_memory = self.current_memory.get().saturating_add(estimated_bytes);
465            if new_memory > max {
466                return Err(ResourceError::Memory {
467                    limit: max,
468                    used: new_memory,
469                });
470            }
471        }
472        Ok(())
473    }
474
475    fn gc_interval(&self) -> Option<usize> {
476        self.limits.gc_interval
477    }
478
479    fn on_execution_start(&self) {
480        debug_assert!(
481            self.running_since.get().is_none(),
482            "nested on_execution_start: VM-internal re-entry must use the raw run loop, not run_external"
483        );
484        self.running_since.set(Some(Instant::now()));
485    }
486
487    fn on_execution_stop(&self) {
488        if let Some(started) = self.running_since.take() {
489            self.total_execution_time
490                .set(self.total_execution_time.get() + started.elapsed());
491        }
492    }
493
494    /// Lowers the live recursion ceiling to `new_limit`, refusing to raise it.
495    ///
496    /// The constructed limit (`limits.max_recursion_depth`) acts as the hard
497    /// upper bound — `sys.setrecursionlimit` may only tighten it, never relax
498    /// it. Crossing from "no limit configured" to a concrete value counts as
499    /// lowering (infinity → finite); going from `Some(N)` to `Some(K)` with
500    /// `K > N` is rejected.
501    #[cfg(feature = "test-hooks")]
502    fn lower_recursion_limit(&self, new_limit: usize) -> Result<(), Option<usize>> {
503        if let Some(current) = self.active_recursion_limit()
504            && new_limit > current
505        {
506            return Err(Some(current));
507        }
508        self.recursion_limit_override.set(Some(new_limit));
509        Ok(())
510    }
511}