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(target_arch = "wasm32")]
5use std::hint;
6#[cfg(not(target_arch = "wasm32"))]
7use std::thread;
8#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
9use std::time::Instant;
10use std::{
11    cell::Cell,
12    error::Error,
13    fmt,
14    sync::atomic::{AtomicUsize, Ordering},
15    time::Duration,
16};
17
18// `std::time::Instant::now()` panics ("time not implemented on this platform")
19// on `wasm32-unknown-unknown`, so any duration limit aborts there. Swap in
20// `web_time::Instant` (a `performance.now()`-backed drop-in) only for that
21// target; every other target (native, WASI) keeps std, so the `web-time`
22// dependency is pulled in only where it's needed (see Cargo.toml).
23#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
24use web_time::Instant;
25
26/// Exit code a worker uses when it exceeded its memory limit or the allocator
27/// refused an allocation, so the parent can report `MemoryError` instead of an
28/// unclassifiable `SIGABRT`.
29///
30/// `EX_DATAERR` from BSD `sysexits.h`. See <https://man.freebsd.org/cgi/man.cgi?query=sysexits>.
31pub const OOM_EXIT_CODE: i32 = 65;
32/// Allocator-backed live bytes requested through the global allocator
33pub static LIVE_MEMORY: AtomicUsize = AtomicUsize::new(0);
34/// What the worker costs to exist: the leanest the process has ever been at an
35/// arming point, plus structures it keeps for life (see [`allocate_into_baseline`]).
36pub static BASELINE_MEMORY: AtomicUsize = AtomicUsize::new(usize::MAX);
37
38/// Runs `build` and adds what it leaves allocated to [`BASELINE_MEMORY`], so a
39/// structure the worker keeps for life (such as typeshed) is not charged to the
40/// session that happened to build it first.
41///
42/// Only accurate while no other thread changes [`LIVE_MEMORY`]. An unset baseline is
43/// left alone: the first arming counts the structure anyway.
44pub fn allocate_into_baseline<T>(build: impl FnOnce() -> T) -> T {
45    let before = LIVE_MEMORY.load(Ordering::Relaxed);
46    let value = build();
47    let cost = LIVE_MEMORY.load(Ordering::Relaxed).saturating_sub(before);
48    // `Err` just means the baseline is still unset
49    let _ = BASELINE_MEMORY.try_update(Ordering::Relaxed, Ordering::Relaxed, |baseline| {
50        (baseline != usize::MAX).then(|| baseline.saturating_add(cost))
51    });
52    value
53}
54
55/// Headroom for exception machinery and work between interpreter checkpoints.
56const MEMORY_LIMIT_HEADROOM: usize = 4 * 1024 * 1024;
57/// Extra headroom for type-checker stubs and caches outside Python execution.
58const TYPE_CHECK_MEMORY_LIMIT_HEADROOM: usize = 32 * 1024 * 1024;
59
60/// Converts an interpreter soft memory limit into a worker allocator budget.
61///
62/// The returned budget includes operational headroom but remains relative to
63/// the worker baseline, which `monty-alloc` adds when arming its hard ceiling.
64#[must_use]
65pub fn memory_limit_with_headroom(max_memory: Option<usize>, type_check: bool) -> Option<usize> {
66    max_memory.map(|bytes| {
67        let headroom = if type_check {
68            TYPE_CHECK_MEMORY_LIMIT_HEADROOM
69        } else {
70            MEMORY_LIMIT_HEADROOM
71        };
72        bytes.saturating_add(headroom)
73    })
74}
75
76/// Threshold in bytes above which `check_large_result` is called.
77///
78/// Operations that may produce results larger than this threshold (100KB) should call
79/// `check_large_result` before performing the operation. This prevents DoS attacks
80/// where operations like `2 ** 10_000_000` allocate huge amounts of memory before
81/// the memory check can catch them.
82pub const LARGE_RESULT_THRESHOLD: usize = 100_000;
83/// Error returned when a resource limit is exceeded during execution.
84///
85/// This allows the sandbox to enforce strict limits on execution time
86/// and memory usage.
87///
88/// All variants except `Recursion` are **uncatchable** inside the sandbox:
89/// untrusted code must never intercept resource enforcement. `Recursion`
90/// surfaces as a catchable `RecursionError`, matching CPython.
91#[derive(Debug, Clone)]
92pub enum ResourceError {
93    /// One of the two execution-time budgets was exceeded; `scope` says which.
94    Time {
95        scope: TimeLimitScope,
96        limit: Duration,
97        elapsed: Duration,
98    },
99    /// Maximum memory usage exceeded.
100    Memory { limit: usize, used: usize },
101    /// Maximum recursion depth exceeded.
102    Recursion { limit: usize, depth: usize },
103}
104
105/// Which of the two nested execution-time budgets a [`ResourceError::Time`]
106/// refers to.
107///
108/// Both read the same clock and differ only in when they are reset, so a
109/// turn's time is charged to its feed as well: `Feed >= Turn` always holds.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum TimeLimitScope {
112    /// [`ResourceLimits::max_feed_duration`] — reset at each feed.
113    Feed,
114    /// [`ResourceLimits::max_turn_duration`] — reset at each feed and each
115    /// resume.
116    Turn,
117}
118
119impl TimeLimitScope {
120    /// The word naming this scope in an error message.
121    fn prefix(self) -> &'static str {
122        match self {
123            Self::Feed => "feed ",
124            Self::Turn => "turn ",
125        }
126    }
127}
128
129impl fmt::Display for ResourceError {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        match self {
132            Self::Time { scope, limit, elapsed } => {
133                write!(f, "{}time limit exceeded: {elapsed:?} > {limit:?}", scope.prefix())
134            }
135            Self::Memory { limit, used } => {
136                write!(f, "memory limit exceeded: {used} bytes > {limit} bytes")
137            }
138            Self::Recursion { .. } => {
139                write!(f, "maximum recursion depth exceeded")
140            }
141        }
142    }
143}
144
145impl Error for ResourceError {}
146
147/// Configuration for resource limits.
148///
149/// The time/memory/GC limits are optional — set to `None` to disable — but
150/// recursion depth and the suspension budget are always bounded (defaults
151/// [`DEFAULT_MAX_RECURSION_DEPTH`] and [`DEFAULT_MAX_SUSPENSIONS`]): unbounded
152/// recursion would let sandboxed code overflow the native stack and abort the
153/// process, and unbounded suspensions would let it loop on host calls. Use
154/// `ResourceLimits::default()` for the recursion-only defaults, or build
155/// custom limits with the builder pattern.
156#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
157pub struct ResourceLimits {
158    /// Maximum execution time for a single feed (`feed_start`, `feed_run` or
159    /// `call_function`), summed over the turns it takes and excluding time
160    /// suspended on the host. Bounds one snippet, not the session.
161    pub max_feed_duration: Option<Duration>,
162    /// Maximum execution time for a single host turn, reset at each feed and
163    /// each resume. Bounds the stretch of sandbox code between two host round
164    /// trips, so a host can bound its own response time per call.
165    pub max_turn_duration: Option<Duration>,
166    /// Maximum allocator-backed memory in bytes.
167    ///
168    /// Requires the executable to install and arm `monty-alloc`.
169    pub max_memory: Option<usize>,
170    /// Run garbage collection every N GC-tracked allocations; `None` uses [`DEFAULT_GC_INTERVAL`].
171    pub gc_interval: Option<usize>,
172    /// Maximum recursion depth (function call stack depth).
173    pub max_recursion_depth: usize,
174    /// Maximum suspensions the host may service (default
175    /// [`DEFAULT_MAX_SUSPENSIONS`]; always bounded, like recursion depth).
176    /// The interpreter only stores this limit; hosts must enforce it.
177    pub max_suspensions: usize,
178    /// Maximum cumulative sleep under `SleepMode::System`, enforced by the host.
179    /// Sleeps do not count toward execution duration; without this limit, sleeping
180    /// loops need `max_suspensions` or a host deadline.
181    pub max_total_sleep: Option<Duration>,
182}
183
184/// Recommended maximum recursion depth if not otherwise specified.
185pub const DEFAULT_MAX_RECURSION_DEPTH: usize = 1000;
186
187/// GC-tracked allocations between cycle collections when `gc_interval` is `None`.
188/// Builds with monty's `memory-model-checks` feature collect on every allocation instead.
189pub const DEFAULT_GC_INTERVAL: usize = 100_000;
190
191/// Maximum suspensions a host services per session if not otherwise
192/// specified: a backstop against a sandbox looping on host calls while the
193/// execution clock is paused.
194pub const DEFAULT_MAX_SUSPENSIONS: usize = 1000;
195
196/// Creates a new ResourceLimits with all limits disabled, except max recursion
197/// depth and max suspensions, which are set to 1000.
198impl Default for ResourceLimits {
199    fn default() -> Self {
200        Self {
201            max_feed_duration: None,
202            max_turn_duration: None,
203            max_memory: None,
204            gc_interval: None,
205            max_recursion_depth: DEFAULT_MAX_RECURSION_DEPTH,
206            max_suspensions: DEFAULT_MAX_SUSPENSIONS,
207            max_total_sleep: None,
208        }
209    }
210}
211
212impl ResourceLimits {
213    /// Sets the maximum execution duration for any single feed.
214    #[must_use]
215    pub fn max_feed_duration(mut self, limit: Duration) -> Self {
216        self.max_feed_duration = Some(limit);
217        self
218    }
219
220    /// Sets the maximum execution duration for any single host turn.
221    #[must_use]
222    pub fn max_turn_duration(mut self, limit: Duration) -> Self {
223        self.max_turn_duration = Some(limit);
224        self
225    }
226
227    /// Sets allocator-backed maximum memory usage in bytes.
228    ///
229    /// Requires the executable to install and arm `monty-alloc`; otherwise
230    /// the limit is silently not enforced.
231    #[must_use]
232    pub fn max_memory(mut self, limit: usize) -> Self {
233        self.max_memory = Some(limit);
234        self
235    }
236
237    /// Sets the garbage collection interval (run GC every N GC-tracked allocations).
238    #[must_use]
239    pub fn gc_interval(mut self, interval: usize) -> Self {
240        self.gc_interval = Some(interval);
241        self
242    }
243
244    /// Sets the maximum recursion depth (function call stack depth).
245    #[must_use]
246    pub fn max_recursion_depth(mut self, limit: usize) -> Self {
247        self.max_recursion_depth = limit;
248        self
249    }
250
251    /// Sets the host-enforced maximum number of suspensions.
252    #[must_use]
253    pub fn max_suspensions(mut self, limit: usize) -> Self {
254        self.max_suspensions = limit;
255        self
256    }
257
258    /// Sets the maximum cumulative time hosts wait on system sleeps; each capped delay is charged before the wait.
259    #[must_use]
260    pub fn max_total_sleep(mut self, limit: Duration) -> Self {
261        self.max_total_sleep = Some(limit);
262        self
263    }
264}
265
266/// A resource tracker that enforces configurable limits.
267///
268/// Checks allocator-backed memory usage and tracks execution time, returning
269/// errors when limits are exceeded. It also schedules garbage collection.
270///
271/// Uses `Cell` for mutable timing and recursion state behind shared references.
272///
273/// The two duration limits share one *execution time* clock: it runs only
274/// between the outermost `on_execution_start`/`on_execution_stop` pair, so it
275/// is paused while suspended on the host and between REPL feeds. They differ
276/// only in when their accumulator resets — at
277/// [`on_feed_start`](Self::on_feed_start), at [`on_turn_start`](Self::on_turn_start)
278/// — and the feed total is serialized, so a session loaded mid-feed resumes
279/// that budget rather than restarting from zero.
280#[derive(Debug, serde::Serialize, serde::Deserialize)]
281pub struct ResourceTracker {
282    limits: ResourceLimits,
283    /// Execution time accumulated by completed `on_execution_start`/`stop`
284    /// windows. Bounds nothing — it is what [`elapsed`](Self::elapsed) reports
285    /// to hosts for telemetry — but is serialized so a loaded session keeps
286    /// counting from where it left off.
287    total_execution_time: Cell<Duration>,
288    /// Execution time accumulated since the last [`on_feed_start`](Self::on_feed_start).
289    /// Serialized like `total_execution_time`: a dump taken mid-feed resumes
290    /// that feed, so its budget must survive the round trip.
291    feed_execution_time: Cell<Duration>,
292    /// Execution time accumulated since the last [`on_turn_start`](Self::on_turn_start).
293    /// Not serialized — a dump is taken between turns, and the resume that
294    /// loads it starts a fresh turn.
295    #[serde(skip)]
296    turn_execution_time: Cell<Duration>,
297    /// When the current execution window started; `None` while suspended or
298    /// idle. Never serialized — a snapshot is by definition taken while not
299    /// executing.
300    #[serde(skip)]
301    running_since: Cell<Option<Instant>>,
302    /// Optional override applied on top of `limits.max_recursion_depth`.
303    ///
304    /// `None` (the default — also the value any pre-`test-hooks` snapshot
305    /// deserializes to) means "no override, use the configured ceiling".
306    /// `Some(N)` means "use `N` as the live recursion ceiling instead", and
307    /// is only ever populated by
308    /// [`lower_recursion_limit`](Self::lower_recursion_limit)
309    /// under the `test-hooks` feature — `sys.setrecursionlimit` uses it to
310    /// tighten the bound from Python code without escaping the
311    /// host-configured ceiling. `None` means the configured limit applies.
312    recursion_limit_override: Cell<Option<usize>>,
313}
314
315impl Default for ResourceTracker {
316    fn default() -> Self {
317        Self::new(ResourceLimits::default())
318    }
319}
320
321impl ResourceTracker {
322    /// Tightens restored limits to the destination policy without resetting execution clocks.
323    /// A snapshot can retain stricter limits but cannot increase the destination's allowance.
324    pub fn tighten_limits(&mut self, ceiling: &ResourceLimits) {
325        self.limits.max_memory = tighter_limit(self.limits.max_memory, ceiling.max_memory);
326        self.limits.max_feed_duration = tighter_limit(self.limits.max_feed_duration, ceiling.max_feed_duration);
327        self.limits.max_turn_duration = tighter_limit(self.limits.max_turn_duration, ceiling.max_turn_duration);
328        self.limits.max_total_sleep = tighter_limit(self.limits.max_total_sleep, ceiling.max_total_sleep);
329        self.limits.gc_interval = tighter_gc_interval(self.limits.gc_interval, ceiling.gc_interval);
330        self.limits.max_recursion_depth = self.limits.max_recursion_depth.min(ceiling.max_recursion_depth);
331        self.limits.max_suspensions = self.limits.max_suspensions.min(ceiling.max_suspensions);
332        if let Some(limit) = self.recursion_limit_override.get() {
333            self.recursion_limit_override
334                .set(Some(limit.min(self.limits.max_recursion_depth)));
335        }
336    }
337
338    /// Creates a new ResourceTracker with the given limits.
339    ///
340    /// The execution-time clock starts at zero and only runs while the VM
341    /// executes, so the tracker can be created any amount of time before
342    /// the first run without consuming a duration budget. A configured
343    /// `max_memory` requires `monty-alloc` installed as the global allocator
344    /// and armed via `set_hard_limit(memory_limit_with_headroom(...))`;
345    /// otherwise it is silently not enforced.
346    #[must_use]
347    pub fn new(limits: ResourceLimits) -> Self {
348        Self {
349            limits,
350            total_execution_time: Cell::new(Duration::ZERO),
351            feed_execution_time: Cell::new(Duration::ZERO),
352            turn_execution_time: Cell::new(Duration::ZERO),
353            running_since: Cell::new(None),
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    #[inline]
361    fn active_recursion_limit(&self) -> usize {
362        self.recursion_limit_override
363            .get()
364            .unwrap_or(self.limits.max_recursion_depth)
365    }
366
367    /// Returns the cumulative execution time: bytecode-execution wall time
368    /// accumulated across runs/feeds, excluding time suspended on the host
369    /// or idle between feeds. Includes the in-progress window if the VM is
370    /// currently executing.
371    ///
372    /// Nothing is bounded by this — it is reported to the host for telemetry.
373    /// The budgets are [`feed_elapsed`](Self::feed_elapsed) and
374    /// [`turn_elapsed`](Self::turn_elapsed).
375    #[must_use]
376    pub fn elapsed(&self) -> Duration {
377        self.total_execution_time.get() + self.running_window()
378    }
379
380    /// Returns the execution time consumed by the current feed; see
381    /// [`elapsed`](Self::elapsed), which reports the same clock over the
382    /// whole session.
383    #[must_use]
384    pub fn feed_elapsed(&self) -> Duration {
385        self.feed_execution_time.get() + self.running_window()
386    }
387
388    /// Returns the execution time consumed by the current host turn; see
389    /// [`elapsed`](Self::elapsed), which reports the same clock over the
390    /// whole session.
391    #[must_use]
392    pub fn turn_elapsed(&self) -> Duration {
393        self.turn_execution_time.get() + self.running_window()
394    }
395
396    /// The in-progress execution window, or zero when not executing. Read
397    /// once per check so both budgets are compared against one clock reading
398    /// rather than two.
399    #[inline]
400    fn running_window(&self) -> Duration {
401        self.running_since.get().map_or(Duration::ZERO, |t| t.elapsed())
402    }
403
404    /// Returns the configured per-feed execution time limit, if any.
405    #[must_use]
406    pub fn max_feed_duration(&self) -> Option<Duration> {
407        self.limits.max_feed_duration
408    }
409
410    /// Returns the configured per-turn execution time limit, if any.
411    #[must_use]
412    pub fn max_turn_duration(&self) -> Option<Duration> {
413        self.limits.max_turn_duration
414    }
415
416    /// Returns the configured memory budget, if any. Hosts that bound a worker
417    /// process from outside the interpreter size that bound from this.
418    #[must_use]
419    pub fn max_memory(&self) -> Option<usize> {
420        self.limits.max_memory
421    }
422
423    /// Returns the host-enforced suspension budget (default
424    /// [`DEFAULT_MAX_SUSPENSIONS`]; never unlimited).
425    #[must_use]
426    pub fn max_suspensions(&self) -> usize {
427        self.limits.max_suspensions
428    }
429
430    /// Cumulative sleep limit for the host to enforce, including after restore.
431    #[must_use]
432    pub fn max_total_sleep(&self) -> Option<Duration> {
433        self.limits.max_total_sleep
434    }
435
436    /// Returns whether the VM has a memory or time limit configured.
437    #[must_use]
438    pub fn has_memory_time_limit(&self) -> bool {
439        self.limits.max_memory.is_some() || self.has_time_limit()
440    }
441
442    /// Returns whether either execution-time budget is configured.
443    ///
444    /// Public so callers that pay for finer-grained clock polling only when a
445    /// budget exists (`fstring`'s incremental large-result path) can ask.
446    #[must_use]
447    #[inline]
448    pub fn has_time_limit(&self) -> bool {
449        self.limits.max_feed_duration.is_some() || self.limits.max_turn_duration.is_some()
450    }
451
452    /// Sets the per-feed execution limit as a fresh budget from now, resetting
453    /// the feed (and so the turn) clock.
454    ///
455    /// This lets a host enforce a different (typically shorter) time limit
456    /// for a resumed phase — e.g. allowing a long build phase, then giving
457    /// `repr()` of the result only a few milliseconds. Time spent suspended
458    /// in the host never counts toward the budget either way.
459    pub fn set_max_feed_duration(&mut self, duration: Duration) {
460        self.limits.max_feed_duration = Some(duration);
461        self.on_feed_start();
462    }
463
464    /// Sets the per-turn execution limit as a fresh budget from now, resetting
465    /// the turn clock.
466    pub fn set_max_turn_duration(&mut self, duration: Duration) {
467        self.limits.max_turn_duration = Some(duration);
468        self.on_turn_start();
469    }
470
471    /// Checks whether one up-front allocation fits the memory budget.
472    ///
473    /// Use this before reserving a buffer that could cross both the soft and
474    /// hard allocator limits before execution reaches another checkpoint.
475    #[inline]
476    pub fn check_allocation(&self, additional: usize) -> Result<(), ResourceError> {
477        if let Some(limit) = self.limits.max_memory {
478            let used = probe_memory().saturating_add(additional);
479            if used > limit {
480                return Err(ResourceError::Memory { limit, used });
481            }
482        }
483        Ok(())
484    }
485
486    /// Called periodically to check allocator-backed memory and time limits.
487    ///
488    /// Returns `Ok(())` while configured limits are respected, or the relevant
489    /// resource error once either limit is exceeded.
490    ///
491    /// Takes `&self` rather than `&mut self` because checking elapsed time is a
492    /// read-only operation. This allows time checks in contexts that only have
493    /// an immutable heap reference, such as `py_repr_fmt`.
494    #[inline]
495    pub fn check_memory_time(&self) -> Result<(), ResourceError> {
496        if let Some(limit) = self.limits.max_memory {
497            let used = probe_memory();
498            if used > limit {
499                return Err(ResourceError::Memory { limit, used });
500            }
501        }
502
503        self.check_time()
504    }
505
506    /// Called periodically to check both execution-time budgets.
507    ///
508    /// Each clock is monotonic within its scope, so once a budget is exceeded
509    /// every later call in that scope fails too. The feed budget is tested
510    /// first, since it is the one a new turn cannot recover from.
511    #[inline]
512    pub fn check_time(&self) -> Result<(), ResourceError> {
513        if !self.has_time_limit() {
514            return Ok(());
515        }
516        // One clock reading shared by both comparisons.
517        let running = self.running_window();
518        check_budget(
519            TimeLimitScope::Feed,
520            self.limits.max_feed_duration,
521            self.feed_execution_time.get() + running,
522        )
523        .and_then(|()| {
524            check_budget(
525                TimeLimitScope::Turn,
526                self.limits.max_turn_duration,
527                self.turn_execution_time.get() + running,
528            )
529        })
530    }
531
532    /// Items processed between full checks in amortized per-item Rust loops
533    /// (see [`check_time_every`](Self::check_time_every)). A limit can be
534    /// overshot by up to this many items' work before the next check — an
535    /// accepted trade for cheap loops; the process-level hard limits backstop
536    /// pathological cases.
537    pub const LOOP_CHECK_INTERVAL: usize = 64;
538
539    /// Amortized per-item time check for Rust-side loops: a full clock read
540    /// once per [`LOOP_CHECK_INTERVAL`](Self::LOOP_CHECK_INTERVAL) calls,
541    /// free otherwise. Key `i` on the loop's index or a monotonically
542    /// increasing counter. Fires at the *end* of each block (`i % N == N-1`)
543    /// so loops shorter than the interval pay no clock read at all — the VM
544    /// dispatch checkpoint covers cadence between short calls.
545    #[inline]
546    pub fn check_time_every(&self, i: usize) -> Result<(), ResourceError> {
547        if i % Self::LOOP_CHECK_INTERVAL == Self::LOOP_CHECK_INTERVAL - 1 {
548            self.check_time()
549        } else {
550            Ok(())
551        }
552    }
553
554    /// Amortized per-item memory + time check; the memory-probing sibling of
555    /// [`check_time_every`](Self::check_time_every), for loops that allocate
556    /// per item. Between full checks the allocator's hard limit still bounds
557    /// runaway growth.
558    #[inline]
559    pub fn check_memory_time_every(&self, i: usize) -> Result<(), ResourceError> {
560        if i % Self::LOOP_CHECK_INTERVAL == Self::LOOP_CHECK_INTERVAL - 1 {
561            self.check_memory_time()
562        } else {
563            Ok(())
564        }
565    }
566
567    /// Preflights the reallocation that pushing one more element onto a dense
568    /// buffer causes; a push that fits the existing capacity costs nothing.
569    ///
570    /// A `Vec` charges its whole doubling in one allocation, so a push
571    /// straddling the soft limit can land past the allocator's fixed
572    /// hard-limit headroom, killing the worker with no checkpoint in between
573    /// at which to raise `MemoryError`. Only for the one-push shape: a bulk
574    /// reservation needs [`ResourceTracker::check_allocation`] sized for the
575    /// whole result, since preflighting less than the final buffer — one
576    /// operand of a merge, say — leaves the same window open.
577    #[inline]
578    pub fn check_growth(&self, len: usize, capacity: usize, elem_size: usize) -> Result<(), ResourceError> {
579        self.check_pending_allocation(Self::growth_bytes(len, capacity, elem_size))
580    }
581
582    /// The bytes [`check_growth`](Self::check_growth) would preflight, or zero
583    /// if the push allocates nothing.
584    ///
585    /// Split out for containers that grow two buffers on one insertion, such
586    /// as a dict's entry vector and index table: checking each increment alone
587    /// passes both while their sum clears the headroom, so the caller sums
588    /// them and passes the total to
589    /// [`check_pending_allocation`](Self::check_pending_allocation).
590    #[inline]
591    #[must_use]
592    pub fn growth_bytes(len: usize, capacity: usize, elem_size: usize) -> usize {
593        if len < capacity {
594            0
595        } else {
596            // A buffer growing from nothing jumps straight to
597            // `RawVec::MIN_NON_ZERO_CAP`, which is also what stops the
598            // increment coming out as zero.
599            let min_non_zero_capacity = match elem_size {
600                1 => 8,
601                2..=1024 => 4,
602                _ => 1,
603            };
604            let new_capacity = capacity
605                .saturating_mul(2)
606                .max(len.saturating_add(1))
607                .max(min_non_zero_capacity);
608            new_capacity.saturating_sub(capacity).saturating_mul(elem_size)
609        }
610    }
611
612    /// [`check_allocation`](Self::check_allocation) for preflights whose
613    /// increment may be zero: a push that allocates nothing must not pay for
614    /// the usage probe.
615    #[inline]
616    pub fn check_pending_allocation(&self, additional: usize) -> Result<(), ResourceError> {
617        if additional == 0 {
618            Ok(())
619        } else {
620            self.check_allocation(additional)
621        }
622    }
623
624    /// Called before pushing a new call frame to check recursion depth.
625    ///
626    /// Returns `Ok(())` if within recursion limit, or `Err(ResourceError::Recursion)`
627    /// if the limit would be exceeded. `current_depth` is the call stack depth
628    /// before the new frame is pushed.
629    #[inline]
630    pub fn check_recursion_depth(&self, current_depth: usize) -> Result<(), ResourceError> {
631        let limit = self.active_recursion_limit();
632        // current_depth is before push, so new depth would be current_depth + 1
633        if current_depth >= limit {
634            return Err(ResourceError::Recursion {
635                limit,
636                depth: current_depth + 1,
637            });
638        }
639        Ok(())
640    }
641
642    /// Called before operations that may produce large results (>100KB).
643    ///
644    /// This allows pre-emptive rejection of operations like `2 ** 10_000_000`
645    /// before the memory is actually allocated. The check only happens for
646    /// estimated result sizes above [`LARGE_RESULT_THRESHOLD`] to avoid overhead
647    /// on small operations.
648    #[inline]
649    pub fn check_large_result(&self, estimated_bytes: usize) -> Result<(), ResourceError> {
650        self.check_allocation(estimated_bytes)
651    }
652
653    /// Returns the configured garbage collection interval, in GC-tracked
654    /// allocations.
655    ///
656    /// The cycle collector runs at most once per `gc_interval` GC-tracked
657    /// allocations, and additionally short-circuits when no cycle candidates
658    /// are pending — so programs that never form cycles pay no collector
659    /// cost regardless of their allocation rate. `None` tells the heap to use
660    /// its built-in default scheduling threshold.
661    #[must_use]
662    #[inline]
663    pub fn gc_interval(&self) -> Option<usize> {
664        self.limits.gc_interval
665    }
666
667    /// Called when the VM enters its execution loop from a host boundary
668    /// (`VM::run_external`), starting one execution window.
669    ///
670    /// Paired with [`on_execution_stop`](Self::on_execution_stop) and never
671    /// nested — VM-internal re-entry (task switches, host-initiated function
672    /// evaluation) uses the raw run loop, so its time falls inside the
673    /// enclosing window. The execution-time clock runs between the pair; it is
674    /// *not* running while execution is suspended waiting on the host
675    /// (external function calls) or between feeds.
676    pub fn on_execution_start(&self) {
677        debug_assert!(
678            self.running_since.get().is_none(),
679            "nested on_execution_start: VM-internal re-entry must use the raw run loop, not run_external"
680        );
681        self.running_since.set(Some(Instant::now()));
682    }
683
684    /// Called when the VM leaves its execution loop — on completion, error,
685    /// or suspension at an external call. See [`on_execution_start`](Self::on_execution_start).
686    pub fn on_execution_stop(&self) {
687        if let Some(started) = self.running_since.take() {
688            let window = started.elapsed();
689            for clock in [
690                &self.total_execution_time,
691                &self.feed_execution_time,
692                &self.turn_execution_time,
693            ] {
694                clock.set(clock.get() + window);
695            }
696        }
697    }
698
699    /// Called when the host begins a new feed, resetting the `max_feed_duration`
700    /// budget (and, since a feed opens a turn, the `max_turn_duration` one).
701    ///
702    /// A feed spans every turn its snippet takes, so this must fire only at
703    /// the snippet's first turn — not at the resumes that continue it.
704    pub fn on_feed_start(&self) {
705        self.feed_execution_time.set(Duration::ZERO);
706        self.on_turn_start();
707    }
708
709    /// Called when the host hands control back to the interpreter — at a feed
710    /// or a resume — resetting the `max_turn_duration` budget.
711    ///
712    /// Continuations the VM resolves without the host (an already-settled
713    /// future, a task switch) stay inside the turn that started them.
714    pub fn on_turn_start(&self) {
715        self.turn_execution_time.set(Duration::ZERO);
716    }
717
718    /// Performs a standard-execution sleep without charging `max_feed_duration`.
719    /// Restarts the execution clock only if it was running before the wait.
720    /// All interpreter waits use `block_for` for platform-specific blocking.
721    pub fn sandbox_sleep(&self, duration: Duration) {
722        let was_running = self.running_since.get().is_some();
723        self.on_execution_stop();
724        block_for(duration);
725        if was_running {
726            self.on_execution_start();
727        }
728    }
729
730    /// Lowers the live recursion ceiling to `new_limit`, refusing to raise it.
731    ///
732    /// Exposed under the `test-hooks` feature so `sys.setrecursionlimit` can
733    /// tighten the depth ceiling from inside fixture code. The constructed
734    /// limit (`limits.max_recursion_depth`) acts as the hard upper bound —
735    /// raising it would let sandboxed code escape the host-imposed safety
736    /// bound. A `new_limit` above the active ceiling is rejected with
737    /// `Err(current)`, which callers surface as a `ValueError` in the
738    /// Python layer.
739    #[cfg(feature = "test-hooks")]
740    pub fn lower_recursion_limit(&self, new_limit: usize) -> Result<(), usize> {
741        let limit = self.active_recursion_limit();
742        if new_limit > limit {
743            return Err(limit);
744        }
745        self.recursion_limit_override.set(Some(new_limit));
746        Ok(())
747    }
748}
749
750/// Combines optional ceilings, where an absent limit permits any value.
751fn tighter_limit<T: Ord>(existing: Option<T>, ceiling: Option<T>) -> Option<T> {
752    match (existing, ceiling) {
753        (Some(existing), Some(ceiling)) => Some(existing.min(ceiling)),
754        (existing, ceiling) => existing.or(ceiling),
755    }
756}
757
758/// [`tighter_limit`] for `gc_interval`, where `None` is [`DEFAULT_GC_INTERVAL`], not unlimited.
759fn tighter_gc_interval(existing: Option<usize>, ceiling: Option<usize>) -> Option<usize> {
760    let effective = existing
761        .unwrap_or(DEFAULT_GC_INTERVAL)
762        .min(ceiling.unwrap_or(DEFAULT_GC_INTERVAL));
763    existing.or(ceiling).map(|_| effective)
764}
765
766/// Reports `elapsed` against one optional budget, naming `scope` in the error.
767#[inline]
768fn check_budget(scope: TimeLimitScope, limit: Option<Duration>, elapsed: Duration) -> Result<(), ResourceError> {
769    match limit {
770        Some(limit) if elapsed > limit => Err(ResourceError::Time { scope, limit, elapsed }),
771        _ => Ok(()),
772    }
773}
774
775/// Returns memory used in bytes
776fn probe_memory() -> usize {
777    LIVE_MEMORY
778        .load(Ordering::Relaxed)
779        .saturating_sub(BASELINE_MEMORY.load(Ordering::Relaxed))
780}
781
782#[cfg(not(target_arch = "wasm32"))]
783fn block_for(duration: Duration) {
784    thread::sleep(duration);
785}
786
787/// Blocks for `duration` on wasm by spinning on the monotonic clock:
788/// `std::thread::sleep` needs `wasi:io/poll`, which a browser host serves
789/// only asynchronously. Only standard execution waits here; the wasm worker
790/// suspends its sleeps to the JavaScript host instead.
791#[cfg(target_arch = "wasm32")]
792fn block_for(duration: Duration) {
793    let started = Instant::now();
794    while started.elapsed() < duration {
795        hint::spin_loop();
796    }
797}