Skip to main content

capsule_runtime/
limits.rs

1//! Per-guest execution limits: epoch-based deadlines and store resource caps.
2//!
3//! Every guest this runtime executes — capsule `run`, tool/driver `run`, and each hook
4//! lifecycle call — runs against a `Store` wired with two independent bounds:
5//!
6//! * an **epoch deadline**, set before each invocation and enforced by [`EpochTicker`],
7//!   which advances the engine's epoch on a fixed interval so a guest that never returns
8//!   traps instead of hanging the session; and
9//! * a **resource limiter** ([`ExecutionLimiter`]) capping linear-memory growth, table
10//!   growth, and instance count.
11//!
12//! Both are sourced from the manifest's `capabilities.limits` block, falling back to the
13//! `DEFAULT_*` constants below for any field it omits. A silent manifest means defaults,
14//! never "unlimited".
15//!
16//! ## What an epoch deadline does and does not bound
17//!
18//! Epoch checks are compiled into wasm loop back-edges and function entries, so the
19//! deadline can only fire while guest code is executing. Time spent blocked inside a host
20//! call (notably `wasi:io/poll`) elapses against the same wall-clock budget but cannot be
21//! interrupted until control returns to wasm. This bounds runaway *guest compute*, which is
22//! what makes a spin loop killable; it is not a general-purpose I/O timeout.
23
24use std::{
25    sync::{
26        atomic::{AtomicBool, Ordering},
27        Arc,
28    },
29    thread,
30    time::Duration,
31};
32
33use wasmtime::{Engine, ResourceLimiter, StoreLimits, StoreLimitsBuilder};
34
35/// How often [`EpochTicker`] advances the engine epoch. Also the granularity of every
36/// deadline: a budget is rounded down to a whole number of ticks (minimum one).
37pub(crate) const EPOCH_TICK_INTERVAL: Duration = Duration::from_millis(100);
38
39/// Default cap on guest linear-memory growth — 512 MiB. Chosen to sit far above what any
40/// real capsule, tool, or hook needs while still bounding a runaway allocation loop.
41pub const DEFAULT_MEMORY_BYTES: usize = 512 * 1024 * 1024;
42
43/// Default cap on guest table growth, in elements.
44pub const DEFAULT_TABLE_ELEMENTS: usize = 100_000;
45
46/// Default cap on instances a single store may create. A component instantiates one core
47/// instance per core module, so single digits is typical; this only stops a pathological
48/// instantiation loop.
49pub const DEFAULT_INSTANCES: usize = 1_000;
50
51/// Default wall-clock budget for a single guest invocation — 10 minutes.
52///
53/// Deliberately generous: the budget covers host-call time as well as guest compute (see
54/// the module docs), so a capsule whose `run` drives a long agent loop through host
55/// `invoke` calls must not trip it. Anything wanting a tight bound sets
56/// `capabilities.limits.deadline_seconds` explicitly.
57pub const DEFAULT_DEADLINE_SECONDS: u64 = 600;
58
59/// Default wall-clock budget for a single *hook lifecycle call* — 30 seconds.
60///
61/// An order of magnitude below [`DEFAULT_DEADLINE_SECONDS`], and deliberately so: a hook call
62/// is one bounded piece of work (an HTTP POST to a collector, a scorer invocation), not a
63/// whole agent loop, so the capsule-wide default would let a wedged hook stall the session for
64/// most of ten minutes *per event*. Applies to blocking and async hooks alike, and only when
65/// the operator left `capabilities.limits.deadline_seconds` unset — an explicit value is
66/// honored verbatim for every guest, hooks included. See [`ExecutionLimits::for_hook_calls`].
67pub const HOOK_DEFAULT_DEADLINE_SECONDS: u64 = 30;
68
69const _: () = assert!(
70    HOOK_DEFAULT_DEADLINE_SECONDS < DEFAULT_DEADLINE_SECONDS,
71    "a hook call must not be able to stall a session for the capsule-wide budget"
72);
73
74/// Fully-resolved execution limits for one session's guests: the manifest's
75/// `capabilities.limits` block with every omitted field replaced by its `DEFAULT_*`.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct ExecutionLimits {
78    /// Cap on linear-memory growth, in bytes.
79    pub memory_bytes: usize,
80    /// Cap on table growth, in elements.
81    pub table_elements: usize,
82    /// Cap on instances a single store may create.
83    pub instances: usize,
84    /// Wall-clock budget for a single guest invocation, in seconds.
85    pub deadline_seconds: u64,
86}
87
88impl Default for ExecutionLimits {
89    fn default() -> Self {
90        Self {
91            memory_bytes: DEFAULT_MEMORY_BYTES,
92            table_elements: DEFAULT_TABLE_ELEMENTS,
93            instances: DEFAULT_INSTANCES,
94            deadline_seconds: DEFAULT_DEADLINE_SECONDS,
95        }
96    }
97}
98
99impl ExecutionLimits {
100    /// Resolve a manifest `capabilities.limits` block, substituting the default for each
101    /// field the manifest left out (and for the whole block when it is absent).
102    #[must_use]
103    pub fn resolve(declared: Option<&murmur_artifact::ResourceLimits>) -> Self {
104        let defaults = Self::default();
105        let Some(declared) = declared else {
106            return defaults;
107        };
108        Self {
109            memory_bytes: declared.memory_bytes.unwrap_or(defaults.memory_bytes),
110            table_elements: declared.table_elements.unwrap_or(defaults.table_elements),
111            instances: declared.instances.unwrap_or(defaults.instances),
112            deadline_seconds: declared
113                .deadline_seconds
114                .unwrap_or(defaults.deadline_seconds),
115        }
116    }
117
118    /// This limit block re-flavored for hook lifecycle calls: every resource cap unchanged,
119    /// but the wall-clock budget falls back to [`HOOK_DEFAULT_DEADLINE_SECONDS`] instead of
120    /// [`DEFAULT_DEADLINE_SECONDS`] when the operator declared none.
121    ///
122    /// Takes the *undefaulted* `capabilities.limits.deadline_seconds` rather than reading it
123    /// back off `self`, because that is the only way to tell "the operator set nothing" from
124    /// "the operator explicitly set 600" — the second must be honored verbatim, hooks
125    /// included. Callers get it from [`crate::CapabilityPolicy::hook_limits`].
126    #[must_use]
127    pub fn for_hook_calls(self, declared_deadline_seconds: Option<u64>) -> Self {
128        Self {
129            deadline_seconds: declared_deadline_seconds.unwrap_or(HOOK_DEFAULT_DEADLINE_SECONDS),
130            ..self
131        }
132    }
133
134    /// These hook limits for one hook whose own `artifacts:` entry declares
135    /// `capabilities.limits.deadline_seconds` as `entry_deadline_seconds`, which wins over the
136    /// session's hook deadline. Every resource cap stays capsule-wide.
137    #[must_use]
138    pub(crate) fn for_hook_entry(self, entry_deadline_seconds: Option<u64>) -> Self {
139        Self {
140            deadline_seconds: entry_deadline_seconds.unwrap_or(self.deadline_seconds),
141            ..self
142        }
143    }
144
145    /// This budget expressed in epoch ticks, for `Store::set_epoch_deadline`.
146    ///
147    /// Rounds down to whole ticks but never to zero — a deadline of zero ticks would trap
148    /// the guest before it executed a single instruction.
149    pub(crate) fn deadline_ticks(&self) -> u64 {
150        let tick_ms = EPOCH_TICK_INTERVAL.as_millis() as u64;
151        (self.deadline_seconds.saturating_mul(1_000) / tick_ms).max(1)
152    }
153
154    /// Build the store limiter enforcing these limits.
155    pub(crate) fn limiter(&self) -> ExecutionLimiter {
156        ExecutionLimiter::new(*self)
157    }
158}
159
160/// `ResourceLimiter` enforcing an [`ExecutionLimits`] on one store.
161///
162/// Wraps `wasmtime::StoreLimits` purely to reuse its comparison logic, built with
163/// `trap_on_grow_failure(false)` so a denial reaches us as `Ok(false)` instead of
164/// wasmtime's own untyped `bail!`. We then convert the denial into an error carrying a
165/// message that names the manifest field responsible, and record it.
166///
167/// The recorded flag — not the error type — is what [`classify_guest_failure`] keys on.
168/// wasmtime raises `Trap::AllocationTooLarge` only from its GC and libcall paths, never
169/// from a limiter denial, so downcasting the resulting error to `Trap` cannot identify
170/// this case and matching on message text would be brittle.
171pub(crate) struct ExecutionLimiter {
172    inner: StoreLimits,
173    limits: ExecutionLimits,
174    /// Set the first time this limiter denies a growth request.
175    denial: Option<String>,
176}
177
178impl ExecutionLimiter {
179    fn new(limits: ExecutionLimits) -> Self {
180        let inner = StoreLimitsBuilder::new()
181            .memory_size(limits.memory_bytes)
182            .table_elements(limits.table_elements)
183            .instances(limits.instances)
184            .trap_on_grow_failure(false)
185            .build();
186        Self {
187            inner,
188            limits,
189            denial: None,
190        }
191    }
192
193    /// The limits this limiter enforces.
194    pub(crate) fn limits(&self) -> ExecutionLimits {
195        self.limits
196    }
197
198    /// The message from the first growth request this limiter denied, if any.
199    pub(crate) fn denial(&self) -> Option<&str> {
200        self.denial.as_deref()
201    }
202}
203
204impl ResourceLimiter for ExecutionLimiter {
205    fn memory_growing(
206        &mut self,
207        current: usize,
208        desired: usize,
209        maximum: Option<usize>,
210    ) -> wasmtime::Result<bool> {
211        if self.inner.memory_growing(current, desired, maximum)? {
212            return Ok(true);
213        }
214        let message = format!(
215            "linear memory growth to {desired} bytes exceeds \
216             capabilities.limits.memory_bytes ({})",
217            self.limits.memory_bytes
218        );
219        self.denial = Some(message.clone());
220        Err(wasmtime::Error::msg(message))
221    }
222
223    fn table_growing(
224        &mut self,
225        current: usize,
226        desired: usize,
227        maximum: Option<usize>,
228    ) -> wasmtime::Result<bool> {
229        if self.inner.table_growing(current, desired, maximum)? {
230            return Ok(true);
231        }
232        let message = format!(
233            "table growth to {desired} elements exceeds \
234             capabilities.limits.table_elements ({})",
235            self.limits.table_elements
236        );
237        self.denial = Some(message.clone());
238        Err(wasmtime::Error::msg(message))
239    }
240
241    fn instances(&self) -> usize {
242        self.inner.instances()
243    }
244
245    fn tables(&self) -> usize {
246        self.inner.tables()
247    }
248
249    fn memories(&self) -> usize {
250        self.inner.memories()
251    }
252}
253
254/// Advances `Engine::increment_epoch` every [`EPOCH_TICK_INTERVAL`] for the lifetime of a
255/// session's engine. Without a running ticker an epoch deadline can never fire, so exactly
256/// one of these is spawned per `Engine` in `stage_session` and its guard is held on
257/// `StagedSession` — a `wasmtime::Engine` is an `Arc` handle, so that single ticker reaches
258/// every capsule, tool, and hook store cloned from it.
259pub(crate) struct EpochTicker {
260    stop: Arc<AtomicBool>,
261}
262
263impl EpochTicker {
264    /// Spawn the ticker thread for `engine`.
265    ///
266    /// A plain OS thread rather than a tokio task: staging dispatches `on-stage` hooks on
267    /// its own current-thread runtime while the rest of the session uses a multi-thread
268    /// one, and the ticker must keep running across both regardless of which (if any)
269    /// runtime is entered.
270    ///
271    /// The thread holds only an `EngineWeak`, so it can never keep the engine alive and
272    /// exits on its own once the last `Engine` handle drops. The stop flag covers the
273    /// reverse case — the guard dropping while cloned `Engine` handles still live in store
274    /// states — so the two together bound the thread's life from both ends.
275    pub(crate) fn spawn(engine: &Engine) -> Self {
276        let weak = engine.weak();
277        let stop = Arc::new(AtomicBool::new(false));
278        let thread_stop = Arc::clone(&stop);
279
280        thread::spawn(move || {
281            while !thread_stop.load(Ordering::Relaxed) {
282                thread::sleep(EPOCH_TICK_INTERVAL);
283                match weak.upgrade() {
284                    Some(engine) => engine.increment_epoch(),
285                    None => break,
286                }
287            }
288        });
289
290        Self { stop }
291    }
292}
293
294impl Drop for EpochTicker {
295    /// Signals the ticker thread to exit. Deliberately does not join: the thread notices
296    /// the flag within one tick and exits on its own, and blocking session teardown for up
297    /// to `EPOCH_TICK_INTERVAL` to watch it happen buys nothing.
298    fn drop(&mut self) {
299        self.stop.store(true, Ordering::Relaxed);
300    }
301}
302
303/// Why a guest invocation failed, separating the two limits this module enforces from an
304/// ordinary trap.
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub(crate) enum GuestFailure {
307    /// The epoch deadline fired — the guest ran longer than `deadline_seconds`.
308    DeadlineExceeded { seconds: u64 },
309    /// The store's resource limiter denied a growth request.
310    ResourceLimit { message: String },
311    /// Anything else: guest panic, `unreachable`, host error.
312    Other,
313}
314
315/// The single classification point for every failed guest invocation — capsule `run`,
316/// tool/driver `run`, and hook lifecycle calls all route their `Err` through here so the
317/// three outcomes stay distinguishable without each call site re-deriving the logic.
318///
319/// A limiter denial is checked first and wins over any trap code, because the denial is
320/// recorded at the moment of refusal and is therefore authoritative about the cause; the
321/// error wasmtime surfaces afterwards may have been reshaped on its way out of the guest.
322pub(crate) fn classify_guest_failure(
323    error: &wasmtime::Error,
324    limiter: &ExecutionLimiter,
325) -> GuestFailure {
326    if let Some(message) = limiter.denial() {
327        return GuestFailure::ResourceLimit {
328            message: message.to_string(),
329        };
330    }
331    if matches!(
332        error.downcast_ref::<wasmtime::Trap>(),
333        Some(wasmtime::Trap::Interrupt)
334    ) {
335        return GuestFailure::DeadlineExceeded {
336            seconds: limiter.limits().deadline_seconds,
337        };
338    }
339    GuestFailure::Other
340}
341
342impl GuestFailure {
343    /// Render this failure for the `String`-returning tool/driver and hook paths, which
344    /// report failures as plain text rather than a [`crate::errors::RuntimeError`].
345    ///
346    /// `subject` names the guest, e.g. `tool 'echo'` or `hook 'audit'`. The [`Self::Other`]
347    /// wording is the pre-existing generic phrasing, kept verbatim so only the two limit
348    /// cases read differently.
349    pub(crate) fn message(&self, subject: &str, error: &wasmtime::Error) -> String {
350        match self {
351            Self::DeadlineExceeded { seconds } => format!(
352                "{subject} exceeded its {seconds}s execution deadline \
353                 (capabilities.limits.deadline_seconds) and was interrupted"
354            ),
355            Self::ResourceLimit { message } => {
356                format!("{subject} exceeded its configured resource limits: {message}")
357            }
358            Self::Other => format!("{subject} trapped: {error}"),
359        }
360    }
361
362    /// [`Self::message`] for hook `hook`'s `fn_name` call. A deadline names the key on the hook's
363    /// own `artifacts:` entry, `entry`, which sets that hook's deadline ahead of the capsule-wide
364    /// one.
365    pub(crate) fn hook_message(
366        &self,
367        hook: &str,
368        entry: &str,
369        fn_name: &str,
370        error: &wasmtime::Error,
371    ) -> String {
372        let subject = format!("hook '{hook}' {fn_name}");
373        match self {
374            Self::DeadlineExceeded { seconds } => format!(
375                "{subject} exceeded its {seconds}s execution deadline \
376                 (capabilities.limits.deadline_seconds on the artifacts: entry '{entry}') and was \
377                 interrupted"
378            ),
379            _ => self.message(&subject, error),
380        }
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn resolve_uses_defaults_when_manifest_is_silent() {
390        assert_eq!(ExecutionLimits::resolve(None), ExecutionLimits::default());
391    }
392
393    #[test]
394    fn resolve_fills_each_omitted_field_independently() {
395        let declared = murmur_artifact::ResourceLimits {
396            memory_bytes: Some(1024),
397            table_elements: None,
398            instances: None,
399            deadline_seconds: Some(5),
400        };
401        let limits = ExecutionLimits::resolve(Some(&declared));
402
403        assert_eq!(limits.memory_bytes, 1024);
404        assert_eq!(limits.deadline_seconds, 5);
405        assert_eq!(limits.table_elements, DEFAULT_TABLE_ELEMENTS);
406        assert_eq!(limits.instances, DEFAULT_INSTANCES);
407    }
408
409    /// A hook call gets the lower default when the manifest declared no deadline, while every
410    /// resource cap stays exactly what the capsule-wide resolution produced.
411    #[test]
412    fn hook_calls_get_the_lower_deadline_default_when_none_is_declared() {
413        let capsule = ExecutionLimits::resolve(None);
414        let hook = capsule.for_hook_calls(None);
415
416        assert_eq!(capsule.deadline_seconds, DEFAULT_DEADLINE_SECONDS);
417        assert_eq!(hook.deadline_seconds, HOOK_DEFAULT_DEADLINE_SECONDS);
418        assert_eq!(hook.memory_bytes, capsule.memory_bytes);
419        assert_eq!(hook.table_elements, capsule.table_elements);
420        assert_eq!(hook.instances, capsule.instances);
421    }
422
423    /// An explicitly declared deadline wins for hooks too — including one that happens to
424    /// equal the capsule-wide default, which is why resolution keys on the undefaulted
425    /// `Option` rather than on the resolved value.
426    #[test]
427    fn an_explicit_deadline_is_honored_for_hook_calls() {
428        let declared = murmur_artifact::ResourceLimits {
429            memory_bytes: None,
430            table_elements: None,
431            instances: None,
432            deadline_seconds: Some(DEFAULT_DEADLINE_SECONDS),
433        };
434        let capsule = ExecutionLimits::resolve(Some(&declared));
435
436        assert_eq!(
437            capsule
438                .for_hook_calls(declared.deadline_seconds)
439                .deadline_seconds,
440            DEFAULT_DEADLINE_SECONDS
441        );
442        assert_eq!(capsule.for_hook_calls(Some(5)).deadline_seconds, 5);
443    }
444
445    #[test]
446    fn deadline_ticks_converts_seconds_and_never_yields_zero() {
447        let limits = ExecutionLimits {
448            deadline_seconds: 1,
449            ..ExecutionLimits::default()
450        };
451        // 1s / 100ms per tick.
452        assert_eq!(limits.deadline_ticks(), 10);
453        assert_eq!(ExecutionLimits::default().deadline_ticks(), 6_000);
454    }
455
456    /// A limiter that has denied a growth request classifies as `ResourceLimit` even though
457    /// the resulting error is a plain message with no `Trap` to downcast to — the case the
458    /// recorded-denial design exists to cover.
459    #[test]
460    fn classify_reports_resource_limit_after_a_denial() {
461        let limits = ExecutionLimits {
462            memory_bytes: 1024,
463            ..ExecutionLimits::default()
464        };
465        let mut limiter = limits.limiter();
466        let error = limiter
467            .memory_growing(0, 4096, None)
468            .expect_err("growth past memory_bytes must be denied");
469
470        match classify_guest_failure(&error, &limiter) {
471            GuestFailure::ResourceLimit { message } => {
472                assert!(message.contains("capabilities.limits.memory_bytes"));
473            }
474            other => panic!("expected ResourceLimit, got {other:?}"),
475        }
476    }
477
478    #[test]
479    fn classify_reports_deadline_for_an_interrupt_trap() {
480        let limits = ExecutionLimits {
481            deadline_seconds: 7,
482            ..ExecutionLimits::default()
483        };
484        let limiter = limits.limiter();
485        let error = wasmtime::Error::from(wasmtime::Trap::Interrupt);
486
487        assert_eq!(
488            classify_guest_failure(&error, &limiter),
489            GuestFailure::DeadlineExceeded { seconds: 7 }
490        );
491    }
492
493    #[test]
494    fn a_hook_deadline_names_the_key_on_the_hooks_entry() {
495        let error = wasmtime::Error::from(wasmtime::Trap::Interrupt);
496        let failure = GuestFailure::DeadlineExceeded { seconds: 2 };
497        assert_eq!(
498            failure.hook_message("compact", "local/compact", "on-compaction", &error),
499            "hook 'compact' on-compaction exceeded its 2s execution deadline \
500             (capabilities.limits.deadline_seconds on the artifacts: entry 'local/compact') and \
501             was interrupted"
502        );
503        assert_eq!(
504            failure.message("tool 'echo'", &error),
505            "tool 'echo' exceeded its 2s execution deadline (capabilities.limits.deadline_seconds) \
506             and was interrupted",
507            "every other guest keeps the capsule-wide key"
508        );
509        assert_eq!(
510            GuestFailure::Other.hook_message("h", "local/h", "on-stage", &error),
511            GuestFailure::Other.message("hook 'h' on-stage", &error)
512        );
513    }
514
515    /// An ordinary guest panic must stay in the generic bucket, and must keep the exact
516    /// pre-slice wording on the string-returning paths.
517    #[test]
518    fn classify_reports_other_for_an_unrelated_trap() {
519        let limiter = ExecutionLimits::default().limiter();
520        let error = wasmtime::Error::from(wasmtime::Trap::UnreachableCodeReached);
521
522        let failure = classify_guest_failure(&error, &limiter);
523        assert_eq!(failure, GuestFailure::Other);
524        assert!(failure
525            .message("tool 'echo'", &error)
526            .starts_with("tool 'echo' trapped: "));
527    }
528
529    #[test]
530    fn limiter_allows_growth_within_the_cap() {
531        let limits = ExecutionLimits {
532            memory_bytes: 4096,
533            ..ExecutionLimits::default()
534        };
535        let mut limiter = limits.limiter();
536
537        assert!(limiter.memory_growing(0, 4096, None).unwrap());
538        assert!(limiter.denial().is_none());
539    }
540}