Skip to main content

monty_types/
resource.rs

1//! Resource limits: the [`ResourceTracker`] used by the interpreter heap/VM
2//! and its [`ResourceLimits`] configuration.
3
4#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
5use std::time::Instant;
6use std::{
7    cell::Cell,
8    error::Error,
9    fmt,
10    sync::atomic::{AtomicUsize, Ordering},
11    time::Duration,
12};
13
14// `std::time::Instant::now()` panics ("time not implemented on this platform")
15// on `wasm32-unknown-unknown`, so any `max_duration` limit aborts there. Swap in
16// `web_time::Instant` (a `performance.now()`-backed drop-in) only for that
17// target; every other target (native, WASI) keeps std, so the `web-time`
18// dependency is pulled in only where it's needed (see Cargo.toml).
19#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
20use web_time::Instant;
21
22/// Exit code a worker uses when it exceeded its memory limit or the allocator
23/// refused an allocation, so the parent can report `MemoryError` instead of an
24/// unclassifiable `SIGABRT`.
25///
26/// `EX_DATAERR` from BSD `sysexits.h`. See <https://man.freebsd.org/cgi/man.cgi?query=sysexits>.
27pub const OOM_EXIT_CODE: i32 = 65;
28/// Allocator-backed live bytes requested through the global allocator
29pub static LIVE_MEMORY: AtomicUsize = AtomicUsize::new(0);
30/// The leanest the process has ever been at an arming point: what the worker
31/// costs to exist, before any session ran.
32pub static BASELINE_MEMORY: AtomicUsize = AtomicUsize::new(usize::MAX);
33
34/// Threshold in bytes above which `check_large_result` is called.
35///
36/// Operations that may produce results larger than this threshold (100KB) should call
37/// `check_large_result` before performing the operation. This prevents DoS attacks
38/// where operations like `2 ** 10_000_000` allocate huge amounts of memory before
39/// the memory check can catch them.
40pub const LARGE_RESULT_THRESHOLD: usize = 100_000;
41/// Error returned when a resource limit is exceeded during execution.
42///
43/// This allows the sandbox to enforce strict limits on execution time
44/// and memory usage.
45///
46/// All variants except `Recursion` are **uncatchable** inside the sandbox:
47/// untrusted code must never intercept resource enforcement. `Recursion`
48/// surfaces as a catchable `RecursionError`, matching CPython.
49#[derive(Debug, Clone)]
50pub enum ResourceError {
51    /// Maximum execution time exceeded.
52    Time { limit: Duration, elapsed: Duration },
53    /// Maximum memory usage exceeded.
54    Memory { limit: usize, used: usize },
55    /// Maximum recursion depth exceeded.
56    Recursion { limit: usize, depth: usize },
57}
58
59impl fmt::Display for ResourceError {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            Self::Time { limit, elapsed } => {
63                write!(f, "time limit exceeded: {elapsed:?} > {limit:?}")
64            }
65            Self::Memory { limit, used } => {
66                write!(f, "memory limit exceeded: {used} bytes > {limit} bytes")
67            }
68            Self::Recursion { .. } => {
69                write!(f, "maximum recursion depth exceeded")
70            }
71        }
72    }
73}
74
75impl Error for ResourceError {}
76
77/// Configuration for resource limits.
78///
79/// The time/memory/GC limits are optional — set to `None` to disable — but
80/// recursion depth is always bounded (default
81/// [`DEFAULT_MAX_RECURSION_DEPTH`]): unbounded recursion would let sandboxed
82/// code overflow the native stack and abort the process. Use
83/// `ResourceLimits::default()` for the recursion-only defaults, or build
84/// custom limits with the builder pattern.
85#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
86pub struct ResourceLimits {
87    /// Maximum execution time.
88    pub max_duration: Option<Duration>,
89    /// Maximum allocator-backed memory in bytes.
90    ///
91    /// Requires the executable to install and arm `monty-alloc`.
92    pub max_memory: Option<usize>,
93    /// Run garbage collection every N GC-tracked allocations.
94    pub gc_interval: Option<usize>,
95    /// Maximum recursion depth (function call stack depth).
96    pub max_recursion_depth: usize,
97}
98
99/// Recommended maximum recursion depth if not otherwise specified.
100pub const DEFAULT_MAX_RECURSION_DEPTH: usize = 1000;
101
102/// Creates a new ResourceLimits with all limits disabled, except max recursion which is set to 1000.
103impl Default for ResourceLimits {
104    fn default() -> Self {
105        Self {
106            max_duration: None,
107            max_memory: None,
108            gc_interval: None,
109            max_recursion_depth: DEFAULT_MAX_RECURSION_DEPTH,
110        }
111    }
112}
113
114impl ResourceLimits {
115    /// Sets the maximum execution duration.
116    #[must_use]
117    pub fn max_duration(mut self, limit: Duration) -> Self {
118        self.max_duration = Some(limit);
119        self
120    }
121
122    /// Sets allocator-backed maximum memory usage in bytes.
123    ///
124    /// Requires the executable to install and arm `monty-alloc`; otherwise
125    /// the limit is silently not enforced.
126    #[must_use]
127    pub fn max_memory(mut self, limit: usize) -> Self {
128        self.max_memory = Some(limit);
129        self
130    }
131
132    /// Sets the garbage collection interval (run GC every N GC-tracked allocations).
133    #[must_use]
134    pub fn gc_interval(mut self, interval: usize) -> Self {
135        self.gc_interval = Some(interval);
136        self
137    }
138
139    /// Sets the maximum recursion depth (function call stack depth).
140    #[must_use]
141    pub fn max_recursion_depth(mut self, limit: usize) -> Self {
142        self.max_recursion_depth = limit;
143        self
144    }
145}
146
147/// How often to actually check `Instant::elapsed()` in `check_time`.
148///
149/// Calling `Instant::elapsed()` on every `check_time` invocation adds measurable
150/// overhead in tight loops (the VM calls `check_time` on every instruction).
151/// By only checking every N calls, we reduce this overhead while still catching
152/// timeouts promptly.
153const TIME_CHECK_INTERVAL: u16 = 10;
154
155/// A resource tracker that enforces configurable limits.
156///
157/// Checks allocator-backed memory usage and tracks execution time, returning
158/// errors when limits are exceeded. It also schedules garbage collection.
159///
160/// Uses `Cell` for mutable timing and recursion state behind shared references.
161///
162/// `max_duration` limits *cumulative execution time*: the clock runs only
163/// while the VM is executing bytecode (between the outermost
164/// `on_execution_start`/`on_execution_stop` pair) and is paused while
165/// execution is suspended waiting on the host — external function calls,
166/// OS callbacks — and between REPL feeds. The accumulated time is
167/// serialized, so a deserialized session resumes its budget where it left
168/// off rather than restarting from zero.
169#[derive(Debug, serde::Serialize, serde::Deserialize)]
170pub struct ResourceTracker {
171    limits: ResourceLimits,
172    /// Execution time accumulated by completed `on_execution_start`/`stop`
173    /// windows. Serialized so time budgets survive dump/load. The serde
174    /// default helps self-describing formats; postcard snapshots are
175    /// positional, so older snapshot layouts still fail closed at decode.
176    #[serde(default)]
177    total_execution_time: Cell<Duration>,
178    /// When the current execution window started; `None` while suspended or
179    /// idle. Never serialized — a snapshot is by definition taken while not
180    /// executing.
181    #[serde(skip)]
182    running_since: Cell<Option<Instant>>,
183    /// Counter for rate-limiting `Instant::elapsed()` calls in `check_time`.
184    check_counter: Cell<u16>,
185    /// Optional override applied on top of `limits.max_recursion_depth`.
186    ///
187    /// `None` (the default — also the value any pre-`test-hooks` snapshot
188    /// deserializes to) means "no override, use the configured ceiling".
189    /// `Some(N)` means "use `N` as the live recursion ceiling instead", and
190    /// is only ever populated by
191    /// [`lower_recursion_limit`](Self::lower_recursion_limit)
192    /// under the `test-hooks` feature — `sys.setrecursionlimit` uses it to
193    /// tighten the bound from Python code without escaping the
194    /// host-configured ceiling.
195    ///
196    /// Modeled as an override rather than the live limit so adding this
197    /// field doesn't break deserialization of snapshots produced before it
198    /// existed (`#[serde(default)]` gives back the `None` fallback case).
199    #[serde(default)]
200    recursion_limit_override: Cell<Option<usize>>,
201}
202
203impl Default for ResourceTracker {
204    fn default() -> Self {
205        Self::new(ResourceLimits::default())
206    }
207}
208
209impl ResourceTracker {
210    /// Creates a new ResourceTracker with the given limits.
211    ///
212    /// The execution-time clock starts at zero and only runs while the VM
213    /// executes, so the tracker can be created any amount of time before
214    /// the first run without consuming the duration budget. A configured
215    /// `max_memory` requires `monty-alloc` installed as the global allocator
216    /// and armed via its `set_limit`; otherwise it is silently not enforced.
217    #[must_use]
218    pub fn new(limits: ResourceLimits) -> Self {
219        Self {
220            limits,
221            total_execution_time: Cell::new(Duration::ZERO),
222            running_since: Cell::new(None),
223            check_counter: Cell::new(0),
224            recursion_limit_override: Cell::new(None),
225        }
226    }
227
228    /// Returns the live recursion ceiling: the override if one is in effect,
229    /// otherwise the configured `max_recursion_depth`.
230    #[inline]
231    fn active_recursion_limit(&self) -> usize {
232        self.recursion_limit_override
233            .get()
234            .unwrap_or(self.limits.max_recursion_depth)
235    }
236
237    /// Returns the cumulative execution time: bytecode-execution wall time
238    /// accumulated across runs/feeds, excluding time suspended on the host
239    /// or idle between feeds. Includes the in-progress window if the VM is
240    /// currently executing.
241    #[must_use]
242    pub fn elapsed(&self) -> Duration {
243        let running = self.running_since.get().map_or(Duration::ZERO, |t| t.elapsed());
244        self.total_execution_time.get() + running
245    }
246
247    /// Returns the configured maximum cumulative execution time, if any.
248    #[must_use]
249    pub fn max_duration(&self) -> Option<Duration> {
250        self.limits.max_duration
251    }
252
253    /// Returns the configured memory budget, if any. Hosts that bound a worker
254    /// process from outside the interpreter size that bound from this.
255    #[must_use]
256    pub fn max_memory(&self) -> Option<usize> {
257        self.limits.max_memory
258    }
259
260    /// Sets the maximum execution duration as a fresh budget from now,
261    /// resetting the accumulated execution time to zero.
262    ///
263    /// This lets a host enforce a different (typically shorter) time limit
264    /// for a resumed phase — e.g. allowing a long build phase, then giving
265    /// `repr()` of the result only a few milliseconds. Time spent suspended
266    /// in the host never counts toward the budget either way.
267    pub fn set_max_duration(&mut self, duration: Duration) {
268        self.limits.max_duration = Some(duration);
269        self.total_execution_time.set(Duration::ZERO);
270    }
271
272    /// Checks whether one up-front allocation fits the memory budget.
273    ///
274    /// Use this before reserving a buffer that could cross both the soft and
275    /// hard allocator limits before execution reaches another checkpoint.
276    #[inline]
277    pub fn check_allocation(&self, additional: usize) -> Result<(), ResourceError> {
278        if let Some(limit) = self.limits.max_memory {
279            let used = probe_memory().saturating_add(additional);
280            if used > limit {
281                return Err(ResourceError::Memory { limit, used });
282            }
283        }
284        Ok(())
285    }
286
287    /// Called periodically to check time and allocator-backed memory limits.
288    ///
289    /// Returns `Ok(())` while configured limits are respected, or the relevant
290    /// resource error once either limit is exceeded.
291    ///
292    /// Takes `&self` rather than `&mut self` because checking elapsed time is a
293    /// read-only operation. This allows time checks in contexts that only have
294    /// an immutable heap reference, such as `py_repr_fmt`.
295    #[inline]
296    pub fn check_time(&self) -> Result<(), ResourceError> {
297        if let Some(limit) = self.limits.max_memory {
298            let used = probe_memory();
299            if used > limit {
300                return Err(ResourceError::Memory { limit, used });
301            }
302        }
303
304        if let Some(max) = self.limits.max_duration {
305            self.check_counter.update(|c| c.wrapping_add(1));
306            if self.check_counter.get().is_multiple_of(TIME_CHECK_INTERVAL) {
307                // Only call Instant::elapsed() every TIME_CHECK_INTERVAL calls
308                let elapsed = self.elapsed();
309                if elapsed > max {
310                    // Reset counter so the very next check_time call also triggers
311                    // an elapsed check. This is important because some callers
312                    // (e.g. repr_sequence_fmt) catch the error and return normally,
313                    // and we need the VM loop's next check_time to re-detect timeout.
314                    self.check_counter.set(TIME_CHECK_INTERVAL.wrapping_sub(1));
315                    return Err(ResourceError::Time { limit: max, elapsed });
316                }
317            }
318        }
319        Ok(())
320    }
321
322    /// Called before pushing a new call frame to check recursion depth.
323    ///
324    /// Returns `Ok(())` if within recursion limit, or `Err(ResourceError::Recursion)`
325    /// if the limit would be exceeded. `current_depth` is the call stack depth
326    /// before the new frame is pushed.
327    #[inline]
328    pub fn check_recursion_depth(&self, current_depth: usize) -> Result<(), ResourceError> {
329        let limit = self.active_recursion_limit();
330        // current_depth is before push, so new depth would be current_depth + 1
331        if current_depth >= limit {
332            return Err(ResourceError::Recursion {
333                limit,
334                depth: current_depth + 1,
335            });
336        }
337        Ok(())
338    }
339
340    /// Called before operations that may produce large results (>100KB).
341    ///
342    /// This allows pre-emptive rejection of operations like `2 ** 10_000_000`
343    /// before the memory is actually allocated. The check only happens for
344    /// estimated result sizes above `LARGE_RESULT_THRESHOLD` to avoid overhead
345    /// on small operations.
346    #[inline]
347    pub fn check_large_result(&self, estimated_bytes: usize) -> Result<(), ResourceError> {
348        self.check_allocation(estimated_bytes)
349    }
350
351    /// Returns the configured garbage collection interval, in GC-tracked
352    /// allocations.
353    ///
354    /// The cycle collector runs at most once per `gc_interval` GC-tracked
355    /// allocations, and additionally short-circuits when no cycle candidates
356    /// are pending — so programs that never form cycles pay no collector
357    /// cost regardless of their allocation rate. `None` tells the heap to use
358    /// its built-in default scheduling threshold.
359    #[must_use]
360    #[inline]
361    pub fn gc_interval(&self) -> Option<usize> {
362        self.limits.gc_interval
363    }
364
365    /// Called when the VM enters its execution loop from a host boundary
366    /// (`VM::run_external`), starting one execution window.
367    ///
368    /// Paired with [`on_execution_stop`](Self::on_execution_stop) and never
369    /// nested — VM-internal re-entry (task switches, host-initiated function
370    /// evaluation) uses the raw run loop, so its time falls inside the
371    /// enclosing window. The execution-time clock runs between the pair; it is
372    /// *not* running while execution is suspended waiting on the host
373    /// (external function calls) or between feeds.
374    pub fn on_execution_start(&self) {
375        debug_assert!(
376            self.running_since.get().is_none(),
377            "nested on_execution_start: VM-internal re-entry must use the raw run loop, not run_external"
378        );
379        self.running_since.set(Some(Instant::now()));
380    }
381
382    /// Called when the VM leaves its execution loop — on completion, error,
383    /// or suspension at an external call. See [`on_execution_start`](Self::on_execution_start).
384    pub fn on_execution_stop(&self) {
385        if let Some(started) = self.running_since.take() {
386            self.total_execution_time
387                .set(self.total_execution_time.get() + started.elapsed());
388        }
389    }
390
391    /// Lowers the live recursion ceiling to `new_limit`, refusing to raise it.
392    ///
393    /// Exposed under the `test-hooks` feature so `sys.setrecursionlimit` can
394    /// tighten the depth ceiling from inside fixture code. The constructed
395    /// limit (`limits.max_recursion_depth`) acts as the hard upper bound —
396    /// raising it would let sandboxed code escape the host-imposed safety
397    /// bound. A `new_limit` above the active ceiling is rejected with
398    /// `Err(current)`, which callers surface as a `ValueError` in the
399    /// Python layer.
400    #[cfg(feature = "test-hooks")]
401    pub fn lower_recursion_limit(&self, new_limit: usize) -> Result<(), usize> {
402        let limit = self.active_recursion_limit();
403        if new_limit > limit {
404            return Err(limit);
405        }
406        self.recursion_limit_override.set(Some(new_limit));
407        Ok(())
408    }
409}
410
411/// Returns memory used in bytes
412fn probe_memory() -> usize {
413    LIVE_MEMORY
414        .load(Ordering::Relaxed)
415        .saturating_sub(BASELINE_MEMORY.load(Ordering::Relaxed))
416}