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/// Fully-resolved execution limits for one session's guests: the manifest's
60/// `capabilities.limits` block with every omitted field replaced by its `DEFAULT_*`.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct ExecutionLimits {
63    /// Cap on linear-memory growth, in bytes.
64    pub memory_bytes: usize,
65    /// Cap on table growth, in elements.
66    pub table_elements: usize,
67    /// Cap on instances a single store may create.
68    pub instances: usize,
69    /// Wall-clock budget for a single guest invocation, in seconds.
70    pub deadline_seconds: u64,
71}
72
73impl Default for ExecutionLimits {
74    fn default() -> Self {
75        Self {
76            memory_bytes: DEFAULT_MEMORY_BYTES,
77            table_elements: DEFAULT_TABLE_ELEMENTS,
78            instances: DEFAULT_INSTANCES,
79            deadline_seconds: DEFAULT_DEADLINE_SECONDS,
80        }
81    }
82}
83
84impl ExecutionLimits {
85    /// Resolve a manifest `capabilities.limits` block, substituting the default for each
86    /// field the manifest left out (and for the whole block when it is absent).
87    #[must_use]
88    pub fn resolve(declared: Option<&murmur_artifact::ResourceLimits>) -> Self {
89        let defaults = Self::default();
90        let Some(declared) = declared else {
91            return defaults;
92        };
93        Self {
94            memory_bytes: declared.memory_bytes.unwrap_or(defaults.memory_bytes),
95            table_elements: declared.table_elements.unwrap_or(defaults.table_elements),
96            instances: declared.instances.unwrap_or(defaults.instances),
97            deadline_seconds: declared
98                .deadline_seconds
99                .unwrap_or(defaults.deadline_seconds),
100        }
101    }
102
103    /// This budget expressed in epoch ticks, for `Store::set_epoch_deadline`.
104    ///
105    /// Rounds down to whole ticks but never to zero — a deadline of zero ticks would trap
106    /// the guest before it executed a single instruction.
107    pub(crate) fn deadline_ticks(&self) -> u64 {
108        let tick_ms = EPOCH_TICK_INTERVAL.as_millis() as u64;
109        (self.deadline_seconds.saturating_mul(1_000) / tick_ms).max(1)
110    }
111
112    /// Build the store limiter enforcing these limits.
113    pub(crate) fn limiter(&self) -> ExecutionLimiter {
114        ExecutionLimiter::new(*self)
115    }
116}
117
118/// `ResourceLimiter` enforcing an [`ExecutionLimits`] on one store.
119///
120/// Wraps `wasmtime::StoreLimits` purely to reuse its comparison logic, built with
121/// `trap_on_grow_failure(false)` so a denial reaches us as `Ok(false)` instead of
122/// wasmtime's own untyped `bail!`. We then convert the denial into an error carrying a
123/// message that names the manifest field responsible, and record it.
124///
125/// The recorded flag — not the error type — is what [`classify_guest_failure`] keys on.
126/// wasmtime raises `Trap::AllocationTooLarge` only from its GC and libcall paths, never
127/// from a limiter denial, so downcasting the resulting error to `Trap` cannot identify
128/// this case and matching on message text would be brittle.
129pub(crate) struct ExecutionLimiter {
130    inner: StoreLimits,
131    limits: ExecutionLimits,
132    /// Set the first time this limiter denies a growth request.
133    denial: Option<String>,
134}
135
136impl ExecutionLimiter {
137    fn new(limits: ExecutionLimits) -> Self {
138        let inner = StoreLimitsBuilder::new()
139            .memory_size(limits.memory_bytes)
140            .table_elements(limits.table_elements)
141            .instances(limits.instances)
142            .trap_on_grow_failure(false)
143            .build();
144        Self {
145            inner,
146            limits,
147            denial: None,
148        }
149    }
150
151    /// The limits this limiter enforces.
152    pub(crate) fn limits(&self) -> ExecutionLimits {
153        self.limits
154    }
155
156    /// The message from the first growth request this limiter denied, if any.
157    pub(crate) fn denial(&self) -> Option<&str> {
158        self.denial.as_deref()
159    }
160}
161
162impl ResourceLimiter for ExecutionLimiter {
163    fn memory_growing(
164        &mut self,
165        current: usize,
166        desired: usize,
167        maximum: Option<usize>,
168    ) -> wasmtime::Result<bool> {
169        if self.inner.memory_growing(current, desired, maximum)? {
170            return Ok(true);
171        }
172        let message = format!(
173            "linear memory growth to {desired} bytes exceeds \
174             capabilities.limits.memory_bytes ({})",
175            self.limits.memory_bytes
176        );
177        self.denial = Some(message.clone());
178        Err(wasmtime::Error::msg(message))
179    }
180
181    fn table_growing(
182        &mut self,
183        current: usize,
184        desired: usize,
185        maximum: Option<usize>,
186    ) -> wasmtime::Result<bool> {
187        if self.inner.table_growing(current, desired, maximum)? {
188            return Ok(true);
189        }
190        let message = format!(
191            "table growth to {desired} elements exceeds \
192             capabilities.limits.table_elements ({})",
193            self.limits.table_elements
194        );
195        self.denial = Some(message.clone());
196        Err(wasmtime::Error::msg(message))
197    }
198
199    fn instances(&self) -> usize {
200        self.inner.instances()
201    }
202
203    fn tables(&self) -> usize {
204        self.inner.tables()
205    }
206
207    fn memories(&self) -> usize {
208        self.inner.memories()
209    }
210}
211
212/// Advances `Engine::increment_epoch` every [`EPOCH_TICK_INTERVAL`] for the lifetime of a
213/// session's engine. Without a running ticker an epoch deadline can never fire, so exactly
214/// one of these is spawned per `Engine` in `stage_session` and its guard is held on
215/// `StagedSession` — a `wasmtime::Engine` is an `Arc` handle, so that single ticker reaches
216/// every capsule, tool, and hook store cloned from it.
217pub(crate) struct EpochTicker {
218    stop: Arc<AtomicBool>,
219}
220
221impl EpochTicker {
222    /// Spawn the ticker thread for `engine`.
223    ///
224    /// A plain OS thread rather than a tokio task: staging dispatches `on-stage` hooks on
225    /// its own current-thread runtime while the rest of the session uses a multi-thread
226    /// one, and the ticker must keep running across both regardless of which (if any)
227    /// runtime is entered.
228    ///
229    /// The thread holds only an `EngineWeak`, so it can never keep the engine alive and
230    /// exits on its own once the last `Engine` handle drops. The stop flag covers the
231    /// reverse case — the guard dropping while cloned `Engine` handles still live in store
232    /// states — so the two together bound the thread's life from both ends.
233    pub(crate) fn spawn(engine: &Engine) -> Self {
234        let weak = engine.weak();
235        let stop = Arc::new(AtomicBool::new(false));
236        let thread_stop = Arc::clone(&stop);
237
238        thread::spawn(move || {
239            while !thread_stop.load(Ordering::Relaxed) {
240                thread::sleep(EPOCH_TICK_INTERVAL);
241                match weak.upgrade() {
242                    Some(engine) => engine.increment_epoch(),
243                    None => break,
244                }
245            }
246        });
247
248        Self { stop }
249    }
250}
251
252impl Drop for EpochTicker {
253    /// Signals the ticker thread to exit. Deliberately does not join: the thread notices
254    /// the flag within one tick and exits on its own, and blocking session teardown for up
255    /// to `EPOCH_TICK_INTERVAL` to watch it happen buys nothing.
256    fn drop(&mut self) {
257        self.stop.store(true, Ordering::Relaxed);
258    }
259}
260
261/// Why a guest invocation failed, separating the two limits this module enforces from an
262/// ordinary trap.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub(crate) enum GuestFailure {
265    /// The epoch deadline fired — the guest ran longer than `deadline_seconds`.
266    DeadlineExceeded { seconds: u64 },
267    /// The store's resource limiter denied a growth request.
268    ResourceLimit { message: String },
269    /// Anything else: guest panic, `unreachable`, host error.
270    Other,
271}
272
273/// The single classification point for every failed guest invocation — capsule `run`,
274/// tool/driver `run`, and hook lifecycle calls all route their `Err` through here so the
275/// three outcomes stay distinguishable without each call site re-deriving the logic.
276///
277/// A limiter denial is checked first and wins over any trap code, because the denial is
278/// recorded at the moment of refusal and is therefore authoritative about the cause; the
279/// error wasmtime surfaces afterwards may have been reshaped on its way out of the guest.
280pub(crate) fn classify_guest_failure(
281    error: &wasmtime::Error,
282    limiter: &ExecutionLimiter,
283) -> GuestFailure {
284    if let Some(message) = limiter.denial() {
285        return GuestFailure::ResourceLimit {
286            message: message.to_string(),
287        };
288    }
289    if matches!(
290        error.downcast_ref::<wasmtime::Trap>(),
291        Some(wasmtime::Trap::Interrupt)
292    ) {
293        return GuestFailure::DeadlineExceeded {
294            seconds: limiter.limits().deadline_seconds,
295        };
296    }
297    GuestFailure::Other
298}
299
300impl GuestFailure {
301    /// Render this failure for the `String`-returning tool/driver and hook paths, which
302    /// report failures as plain text rather than a [`crate::errors::RuntimeError`].
303    ///
304    /// `subject` names the guest, e.g. `tool 'echo'` or `hook 'audit'`. The [`Self::Other`]
305    /// wording is the pre-existing generic phrasing, kept verbatim so only the two limit
306    /// cases read differently.
307    pub(crate) fn message(&self, subject: &str, error: &wasmtime::Error) -> String {
308        match self {
309            Self::DeadlineExceeded { seconds } => format!(
310                "{subject} exceeded its {seconds}s execution deadline \
311                 (capabilities.limits.deadline_seconds) and was interrupted"
312            ),
313            Self::ResourceLimit { message } => {
314                format!("{subject} exceeded its configured resource limits: {message}")
315            }
316            Self::Other => format!("{subject} trapped: {error}"),
317        }
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn resolve_uses_defaults_when_manifest_is_silent() {
327        assert_eq!(ExecutionLimits::resolve(None), ExecutionLimits::default());
328    }
329
330    #[test]
331    fn resolve_fills_each_omitted_field_independently() {
332        let declared = murmur_artifact::ResourceLimits {
333            memory_bytes: Some(1024),
334            table_elements: None,
335            instances: None,
336            deadline_seconds: Some(5),
337        };
338        let limits = ExecutionLimits::resolve(Some(&declared));
339
340        assert_eq!(limits.memory_bytes, 1024);
341        assert_eq!(limits.deadline_seconds, 5);
342        assert_eq!(limits.table_elements, DEFAULT_TABLE_ELEMENTS);
343        assert_eq!(limits.instances, DEFAULT_INSTANCES);
344    }
345
346    #[test]
347    fn deadline_ticks_converts_seconds_and_never_yields_zero() {
348        let limits = ExecutionLimits {
349            deadline_seconds: 1,
350            ..ExecutionLimits::default()
351        };
352        // 1s / 100ms per tick.
353        assert_eq!(limits.deadline_ticks(), 10);
354        assert_eq!(ExecutionLimits::default().deadline_ticks(), 6_000);
355    }
356
357    /// A limiter that has denied a growth request classifies as `ResourceLimit` even though
358    /// the resulting error is a plain message with no `Trap` to downcast to — the case the
359    /// recorded-denial design exists to cover.
360    #[test]
361    fn classify_reports_resource_limit_after_a_denial() {
362        let limits = ExecutionLimits {
363            memory_bytes: 1024,
364            ..ExecutionLimits::default()
365        };
366        let mut limiter = limits.limiter();
367        let error = limiter
368            .memory_growing(0, 4096, None)
369            .expect_err("growth past memory_bytes must be denied");
370
371        match classify_guest_failure(&error, &limiter) {
372            GuestFailure::ResourceLimit { message } => {
373                assert!(message.contains("capabilities.limits.memory_bytes"));
374            }
375            other => panic!("expected ResourceLimit, got {other:?}"),
376        }
377    }
378
379    #[test]
380    fn classify_reports_deadline_for_an_interrupt_trap() {
381        let limits = ExecutionLimits {
382            deadline_seconds: 7,
383            ..ExecutionLimits::default()
384        };
385        let limiter = limits.limiter();
386        let error = wasmtime::Error::from(wasmtime::Trap::Interrupt);
387
388        assert_eq!(
389            classify_guest_failure(&error, &limiter),
390            GuestFailure::DeadlineExceeded { seconds: 7 }
391        );
392    }
393
394    /// An ordinary guest panic must stay in the generic bucket, and must keep the exact
395    /// pre-slice wording on the string-returning paths.
396    #[test]
397    fn classify_reports_other_for_an_unrelated_trap() {
398        let limiter = ExecutionLimits::default().limiter();
399        let error = wasmtime::Error::from(wasmtime::Trap::UnreachableCodeReached);
400
401        let failure = classify_guest_failure(&error, &limiter);
402        assert_eq!(failure, GuestFailure::Other);
403        assert!(failure
404            .message("tool 'echo'", &error)
405            .starts_with("tool 'echo' trapped: "));
406    }
407
408    #[test]
409    fn limiter_allows_growth_within_the_cap() {
410        let limits = ExecutionLimits {
411            memory_bytes: 4096,
412            ..ExecutionLimits::default()
413        };
414        let mut limiter = limits.limiter();
415
416        assert!(limiter.memory_growing(0, 4096, None).unwrap());
417        assert!(limiter.denial().is_none());
418    }
419}