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}