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}