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//! ## Two mechanisms for one deadline
17//!
18//! Epoch checks are compiled into wasm loop back-edges and function entries, so the epoch
19//! deadline can only fire while guest code is executing. A call blocked inside a host call — a
20//! `wasi:clocks` sleep, a `wasi:io/poll` on an HTTP response that never comes — is out of its
21//! reach until control returns to wasm.
22//!
23//! Every component call is therefore also wrapped in a wall-clock bound, [`bound_call`], of
24//! `deadline_seconds` plus [`WALL_DEADLINE_GRACE`], measured per phase (instantiation, then the
25//! call) from the moment that phase's future starts. A guest computing past its deadline is
26//! trapped by the epoch first, with the epoch's message; the wall bound fires only where the
27//! epoch cannot, and stops the call by dropping its future, which disposes the guest fiber and
28//! any outbound request pending under it. Each firing is pushed into the session's
29//! [`CallDeadlines`] latch, which the trace drains into `call_deadline` lines.
30
31use std::{
32    future::Future,
33    sync::{
34        atomic::{AtomicBool, Ordering},
35        Arc, Mutex,
36    },
37    thread,
38    time::{Duration, Instant},
39};
40
41use wasmtime::{Engine, ResourceLimiter, StoreLimits, StoreLimitsBuilder};
42
43/// How often [`EpochTicker`] advances the engine epoch. Also the granularity of every
44/// deadline: a budget is rounded down to a whole number of ticks (minimum one).
45pub(crate) const EPOCH_TICK_INTERVAL: Duration = Duration::from_millis(100);
46
47/// Default cap on guest linear-memory growth — 512 MiB. Chosen to sit far above what any
48/// real capsule, tool, or hook needs while still bounding a runaway allocation loop.
49pub const DEFAULT_MEMORY_BYTES: usize = 512 * 1024 * 1024;
50
51/// Default cap on guest table growth, in elements.
52pub const DEFAULT_TABLE_ELEMENTS: usize = 100_000;
53
54/// Default cap on instances a single store may create. A component instantiates one core
55/// instance per core module, so single digits is typical; this only stops a pathological
56/// instantiation loop.
57pub const DEFAULT_INSTANCES: usize = 1_000;
58
59/// What the wall-clock bound on a component call adds to `deadline_seconds`.
60///
61/// Long enough that a guest computing past its deadline is still caught by the epoch trap,
62/// which needs a tick of [`EPOCH_TICK_INTERVAL`] and control back in wasm, so only a call
63/// pending in the host is stopped by the wall bound.
64pub(crate) const WALL_DEADLINE_GRACE: Duration = Duration::from_secs(2);
65
66const _: () = assert!(
67    WALL_DEADLINE_GRACE.as_millis() > EPOCH_TICK_INTERVAL.as_millis(),
68    "the wall bound must leave the epoch at least one tick to fire first"
69);
70
71/// Default wall-clock budget for a single guest invocation — 10 minutes.
72///
73/// Deliberately generous: the budget covers host-call time as well as guest compute (see
74/// the module docs), so a capsule whose `run` drives a long agent loop through host
75/// `invoke` calls must not trip it. Anything wanting a tight bound sets
76/// `capabilities.limits.deadline_seconds` explicitly.
77pub const DEFAULT_DEADLINE_SECONDS: u64 = 600;
78
79/// Default wall-clock budget for a single *hook lifecycle call* — 30 seconds.
80///
81/// An order of magnitude below [`DEFAULT_DEADLINE_SECONDS`], and deliberately so: a hook call
82/// is one bounded piece of work (an HTTP POST to a collector, a scorer invocation), not a
83/// whole agent loop, so the capsule-wide default would let a wedged hook stall the session for
84/// most of ten minutes *per event*. Applies to blocking and async hooks alike, and only when
85/// the operator left `capabilities.limits.deadline_seconds` unset — an explicit value is
86/// honored verbatim for every guest, hooks included. See [`ExecutionLimits::for_hook_calls`].
87pub const HOOK_DEFAULT_DEADLINE_SECONDS: u64 = 30;
88
89const _: () = assert!(
90    HOOK_DEFAULT_DEADLINE_SECONDS < DEFAULT_DEADLINE_SECONDS,
91    "a hook call must not be able to stall a session for the capsule-wide budget"
92);
93
94/// Fully-resolved execution limits for one session's guests: the manifest's
95/// `capabilities.limits` block with every omitted field replaced by its `DEFAULT_*`.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
97pub struct ExecutionLimits {
98    /// Cap on linear-memory growth, in bytes.
99    pub memory_bytes: usize,
100    /// Cap on table growth, in elements.
101    pub table_elements: usize,
102    /// Cap on instances a single store may create.
103    pub instances: usize,
104    /// Wall-clock budget for a single guest invocation, in seconds.
105    pub deadline_seconds: u64,
106}
107
108impl Default for ExecutionLimits {
109    fn default() -> Self {
110        Self {
111            memory_bytes: DEFAULT_MEMORY_BYTES,
112            table_elements: DEFAULT_TABLE_ELEMENTS,
113            instances: DEFAULT_INSTANCES,
114            deadline_seconds: DEFAULT_DEADLINE_SECONDS,
115        }
116    }
117}
118
119impl ExecutionLimits {
120    /// Resolve a manifest `capabilities.limits` block, substituting the default for each
121    /// field the manifest left out (and for the whole block when it is absent).
122    #[must_use]
123    pub fn resolve(declared: Option<&murmur_artifact::ResourceLimits>) -> Self {
124        let defaults = Self::default();
125        let Some(declared) = declared else {
126            return defaults;
127        };
128        Self {
129            memory_bytes: declared.memory_bytes.unwrap_or(defaults.memory_bytes),
130            table_elements: declared.table_elements.unwrap_or(defaults.table_elements),
131            instances: declared.instances.unwrap_or(defaults.instances),
132            deadline_seconds: declared
133                .deadline_seconds
134                .unwrap_or(defaults.deadline_seconds),
135        }
136    }
137
138    /// This limit block re-flavored for hook lifecycle calls: every resource cap unchanged,
139    /// but the wall-clock budget falls back to [`HOOK_DEFAULT_DEADLINE_SECONDS`] instead of
140    /// [`DEFAULT_DEADLINE_SECONDS`] when the operator declared none.
141    ///
142    /// Takes the *undefaulted* `capabilities.limits.deadline_seconds` rather than reading it
143    /// back off `self`, because that is the only way to tell "the operator set nothing" from
144    /// "the operator explicitly set 600" — the second must be honored verbatim, hooks
145    /// included. Callers get it from [`crate::CapabilityPolicy::hook_limits`].
146    #[must_use]
147    pub fn for_hook_calls(self, declared_deadline_seconds: Option<u64>) -> Self {
148        Self {
149            deadline_seconds: declared_deadline_seconds.unwrap_or(HOOK_DEFAULT_DEADLINE_SECONDS),
150            ..self
151        }
152    }
153
154    /// These hook limits for one hook whose own `artifacts:` entry declares
155    /// `capabilities.limits.deadline_seconds` as `entry_deadline_seconds`, which wins over the
156    /// session's hook deadline. Every resource cap stays capsule-wide.
157    #[must_use]
158    pub(crate) fn for_hook_entry(self, entry_deadline_seconds: Option<u64>) -> Self {
159        Self {
160            deadline_seconds: entry_deadline_seconds.unwrap_or(self.deadline_seconds),
161            ..self
162        }
163    }
164
165    /// This budget expressed in epoch ticks, for `Store::set_epoch_deadline`.
166    ///
167    /// Rounds down to whole ticks but never to zero — a deadline of zero ticks would trap
168    /// the guest before it executed a single instruction.
169    pub(crate) fn deadline_ticks(&self) -> u64 {
170        let tick_ms = EPOCH_TICK_INTERVAL.as_millis() as u64;
171        (self.deadline_seconds.saturating_mul(1_000) / tick_ms).max(1)
172    }
173
174    /// The wall-clock bound on one phase of a call under these limits: `deadline_seconds` plus
175    /// [`WALL_DEADLINE_GRACE`], handed to [`bound_call`] or [`CallDeadlines::bound`].
176    pub(crate) fn wall_bound(&self) -> Duration {
177        Duration::from_secs(self.deadline_seconds).saturating_add(WALL_DEADLINE_GRACE)
178    }
179
180    /// Build the store limiter enforcing these limits.
181    pub(crate) fn limiter(&self) -> ExecutionLimiter {
182        ExecutionLimiter::new(*self)
183    }
184}
185
186/// `ResourceLimiter` enforcing an [`ExecutionLimits`] on one store.
187///
188/// Wraps `wasmtime::StoreLimits` purely to reuse its comparison logic, built with
189/// `trap_on_grow_failure(false)` so a denial reaches us as `Ok(false)` instead of
190/// wasmtime's own untyped `bail!`. We then convert the denial into an error carrying a
191/// message that names the manifest field responsible, and record it.
192///
193/// The recorded flag — not the error type — is what [`classify_guest_failure`] keys on.
194/// wasmtime raises `Trap::AllocationTooLarge` only from its GC and libcall paths, never
195/// from a limiter denial, so downcasting the resulting error to `Trap` cannot identify
196/// this case and matching on message text would be brittle.
197pub(crate) struct ExecutionLimiter {
198    inner: StoreLimits,
199    limits: ExecutionLimits,
200    /// Set the first time this limiter denies a growth request.
201    denial: Option<String>,
202}
203
204impl ExecutionLimiter {
205    fn new(limits: ExecutionLimits) -> Self {
206        let inner = StoreLimitsBuilder::new()
207            .memory_size(limits.memory_bytes)
208            .table_elements(limits.table_elements)
209            .instances(limits.instances)
210            .trap_on_grow_failure(false)
211            .build();
212        Self {
213            inner,
214            limits,
215            denial: None,
216        }
217    }
218
219    /// The limits this limiter enforces.
220    pub(crate) fn limits(&self) -> ExecutionLimits {
221        self.limits
222    }
223
224    /// The message from the first growth request this limiter denied, if any.
225    pub(crate) fn denial(&self) -> Option<&str> {
226        self.denial.as_deref()
227    }
228}
229
230impl ResourceLimiter for ExecutionLimiter {
231    fn memory_growing(
232        &mut self,
233        current: usize,
234        desired: usize,
235        maximum: Option<usize>,
236    ) -> wasmtime::Result<bool> {
237        if self.inner.memory_growing(current, desired, maximum)? {
238            return Ok(true);
239        }
240        let message = format!(
241            "linear memory growth to {desired} bytes exceeds \
242             capabilities.limits.memory_bytes ({})",
243            self.limits.memory_bytes
244        );
245        self.denial = Some(message.clone());
246        Err(wasmtime::Error::msg(message))
247    }
248
249    fn table_growing(
250        &mut self,
251        current: usize,
252        desired: usize,
253        maximum: Option<usize>,
254    ) -> wasmtime::Result<bool> {
255        if self.inner.table_growing(current, desired, maximum)? {
256            return Ok(true);
257        }
258        let message = format!(
259            "table growth to {desired} elements exceeds \
260             capabilities.limits.table_elements ({})",
261            self.limits.table_elements
262        );
263        self.denial = Some(message.clone());
264        Err(wasmtime::Error::msg(message))
265    }
266
267    fn instances(&self) -> usize {
268        self.inner.instances()
269    }
270
271    fn tables(&self) -> usize {
272        self.inner.tables()
273    }
274
275    fn memories(&self) -> usize {
276        self.inner.memories()
277    }
278}
279
280/// Advances `Engine::increment_epoch` every [`EPOCH_TICK_INTERVAL`] for the lifetime of a
281/// session's engine. Without a running ticker an epoch deadline can never fire, so exactly
282/// one of these is spawned per `Engine` in `stage_session` and its guard is held on
283/// `StagedSession` — a `wasmtime::Engine` is an `Arc` handle, so that single ticker reaches
284/// every capsule, tool, and hook store cloned from it.
285pub(crate) struct EpochTicker {
286    stop: Arc<AtomicBool>,
287}
288
289impl EpochTicker {
290    /// Spawn the ticker thread for `engine`.
291    ///
292    /// A plain OS thread rather than a tokio task: staging dispatches `on-stage` hooks on
293    /// its own current-thread runtime while the rest of the session uses a multi-thread
294    /// one, and the ticker must keep running across both regardless of which (if any)
295    /// runtime is entered.
296    ///
297    /// The thread holds only an `EngineWeak`, so it can never keep the engine alive and
298    /// exits on its own once the last `Engine` handle drops. The stop flag covers the
299    /// reverse case — the guard dropping while cloned `Engine` handles still live in store
300    /// states — so the two together bound the thread's life from both ends.
301    pub(crate) fn spawn(engine: &Engine) -> Self {
302        let weak = engine.weak();
303        let stop = Arc::new(AtomicBool::new(false));
304        let thread_stop = Arc::clone(&stop);
305
306        thread::spawn(move || {
307            while !thread_stop.load(Ordering::Relaxed) {
308                thread::sleep(EPOCH_TICK_INTERVAL);
309                match weak.upgrade() {
310                    Some(engine) => engine.increment_epoch(),
311                    None => break,
312                }
313            }
314        });
315
316        Self { stop }
317    }
318}
319
320impl Drop for EpochTicker {
321    /// Signals the ticker thread to exit. Deliberately does not join: the thread notices
322    /// the flag within one tick and exits on its own, and blocking session teardown for up
323    /// to `EPOCH_TICK_INTERVAL` to watch it happen buys nothing.
324    fn drop(&mut self) {
325        self.stop.store(true, Ordering::Relaxed);
326    }
327}
328
329/// A component call the wall-clock bound stopped, with how long it had run when it was stopped.
330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
331pub(crate) struct WallDeadline {
332    pub(crate) elapsed: Duration,
333}
334
335/// Await `call` for at most `bound`, measured from now. Past it the future is dropped, which
336/// is what stops a component call pending in a host wait, and the result is a [`WallDeadline`].
337///
338/// The one wall-clock bound every component entry point goes through, by way of
339/// [`CallDeadlines::bound`]. Needs a tokio runtime with its time driver enabled.
340pub(crate) async fn bound_call<F: Future>(
341    bound: Duration,
342    call: F,
343) -> Result<F::Output, WallDeadline> {
344    let started = Instant::now();
345    tokio::time::timeout(bound, call)
346        .await
347        .map_err(|_| WallDeadline {
348            elapsed: started.elapsed(),
349        })
350}
351
352/// What a component is running as when its call is bound, as `call_deadline.role` names it.
353#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
354pub(crate) enum ComponentRole {
355    /// A script capsule's own component.
356    Capsule,
357    /// An inference driver run for a model call: the agent loop's turn or a hook's
358    /// `run-inference`.
359    Driver,
360    /// Every other tool-world component call, a driver reached by name included.
361    Tool,
362    Hook,
363    ProcessDriver,
364}
365
366impl ComponentRole {
367    /// The value `call_deadline.role` carries.
368    pub(crate) fn wire_name(self) -> &'static str {
369        match self {
370            Self::Capsule => "capsule",
371            Self::Driver => "driver",
372            Self::Tool => "tool",
373            Self::Hook => "hook",
374            Self::ProcessDriver => "process_driver",
375        }
376    }
377}
378
379/// One firing of the wall-clock bound, as its `call_deadline` line records it.
380#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
381pub(crate) struct CallDeadline {
382    /// The tool name of the artifact, the hook's name, or the capsule's manifest `name`.
383    pub(crate) component: String,
384    pub(crate) role: ComponentRole,
385    /// `instantiate`, `run`, a hook lifecycle function such as `on-session-start`, or a process
386    /// driver function: `describe`, `launch`, `parse` or `classify-exit`.
387    pub(crate) call: String,
388    pub(crate) deadline_seconds: u64,
389    pub(crate) elapsed_ms: u64,
390}
391
392/// The session's record of every component call the wall-clock bound stopped, filled where no
393/// trace writer is in reach and drained by the one that is.
394///
395/// Created at staging and cloned into everything that runs a component: the capsule store and
396/// every tool or driver store dispatched from it, each hook store, the process driver. The
397/// trace writer holding a clone drains it ahead of every line it writes, so each firing becomes
398/// exactly one `call_deadline` line before the record of the call it cut and before
399/// `session_end`.
400#[derive(Debug, Clone, Default)]
401pub(crate) struct CallDeadlines(Arc<Mutex<Vec<CallDeadline>>>);
402
403impl CallDeadlines {
404    /// [`bound_call`] for one phase of a call under `limits`, recording a firing.
405    ///
406    /// A bound that fires inside another — a hook's `run-inference` driver call, a tool a script
407    /// capsule's `run` invokes — records itself; the outer one never fires for it, since the
408    /// inner call has returned. An outer bound that fires first drops the inner call before it
409    /// can record.
410    pub(crate) async fn bound<F: Future>(
411        &self,
412        component: &str,
413        role: ComponentRole,
414        call: &str,
415        limits: &ExecutionLimits,
416        future: F,
417    ) -> Result<F::Output, WallDeadline> {
418        let deadline = (limits.deadline_seconds, limits.wall_bound());
419        self.bound_within(component, role, call, deadline, future)
420            .await
421    }
422
423    /// [`Self::bound`] with the wall bound given outright beside the `deadline_seconds` it is
424    /// recorded under, so a test need not wait out a whole second.
425    async fn bound_within<F: Future>(
426        &self,
427        component: &str,
428        role: ComponentRole,
429        call: &str,
430        (deadline_seconds, bound): (u64, Duration),
431        future: F,
432    ) -> Result<F::Output, WallDeadline> {
433        let bounded = bound_call(bound, future).await;
434        if let Err(cut) = &bounded {
435            self.lock().push(CallDeadline {
436                component: component.to_string(),
437                role,
438                call: call.to_string(),
439                deadline_seconds,
440                elapsed_ms: u64::try_from(cut.elapsed.as_millis()).unwrap_or(u64::MAX),
441            });
442        }
443        bounded
444    }
445
446    /// Records `cut`, a firing a guest worker's own bound recorded for a call it ran.
447    pub(crate) fn record(&self, cut: CallDeadline) {
448        self.lock().push(cut);
449    }
450
451    /// Takes every firing recorded since the last take, oldest first.
452    pub(crate) fn take(&self) -> Vec<CallDeadline> {
453        std::mem::take(&mut *self.lock())
454    }
455
456    /// Records `cut` as if a bound had fired, for a test of what drains the latch.
457    #[cfg(test)]
458    pub(crate) fn push_for_test(&self, cut: CallDeadline) {
459        self.lock().push(cut);
460    }
461
462    fn lock(&self) -> std::sync::MutexGuard<'_, Vec<CallDeadline>> {
463        self.0
464            .lock()
465            .unwrap_or_else(std::sync::PoisonError::into_inner)
466    }
467}
468
469/// The failure text of a call `subject` the wall-clock bound stopped, on the paths that report
470/// failures as plain text. The epoch's wording is [`GuestFailure::message`].
471pub(crate) fn wall_deadline_message(subject: &str, seconds: u64) -> String {
472    format!(
473        "{subject} waited on the host past its {seconds}s execution deadline \
474         (capabilities.limits.deadline_seconds) and was stopped"
475    )
476}
477
478/// [`wall_deadline_message`] for hook `hook`'s `fn_name` call, naming the key on the hook's own
479/// `artifacts:` entry, `entry`, as [`GuestFailure::hook_message`] does.
480pub(crate) fn wall_deadline_hook_message(
481    hook: &str,
482    entry: &str,
483    fn_name: &str,
484    seconds: u64,
485) -> String {
486    format!(
487        "hook '{hook}' {fn_name} waited on the host past its {seconds}s execution deadline \
488         (capabilities.limits.deadline_seconds on the artifacts: entry '{entry}') and was stopped"
489    )
490}
491
492/// Why a guest invocation failed, separating the two limits this module enforces from an
493/// ordinary trap.
494#[derive(Debug, Clone, PartialEq, Eq)]
495pub(crate) enum GuestFailure {
496    /// The epoch deadline fired — the guest ran longer than `deadline_seconds`.
497    DeadlineExceeded { seconds: u64 },
498    /// The store's resource limiter denied a growth request.
499    ResourceLimit { message: String },
500    /// Anything else: guest panic, `unreachable`, host error.
501    Other,
502}
503
504/// The single classification point for every failed guest invocation — capsule `run`,
505/// tool/driver `run`, and hook lifecycle calls all route their `Err` through here so the
506/// three outcomes stay distinguishable without each call site re-deriving the logic.
507///
508/// A limiter denial is checked first and wins over any trap code, because the denial is
509/// recorded at the moment of refusal and is therefore authoritative about the cause; the
510/// error wasmtime surfaces afterwards may have been reshaped on its way out of the guest.
511pub(crate) fn classify_guest_failure(
512    error: &wasmtime::Error,
513    limiter: &ExecutionLimiter,
514) -> GuestFailure {
515    if let Some(message) = limiter.denial() {
516        return GuestFailure::ResourceLimit {
517            message: message.to_string(),
518        };
519    }
520    if matches!(
521        error.downcast_ref::<wasmtime::Trap>(),
522        Some(wasmtime::Trap::Interrupt)
523    ) {
524        return GuestFailure::DeadlineExceeded {
525            seconds: limiter.limits().deadline_seconds,
526        };
527    }
528    GuestFailure::Other
529}
530
531impl GuestFailure {
532    /// Render this failure for the `String`-returning tool/driver and hook paths, which
533    /// report failures as plain text rather than a [`crate::errors::RuntimeError`].
534    ///
535    /// `subject` names the guest, e.g. `tool 'echo'` or `hook 'audit'`. The [`Self::Other`]
536    /// wording is the pre-existing generic phrasing, kept verbatim so only the two limit
537    /// cases read differently.
538    pub(crate) fn message(&self, subject: &str, error: &wasmtime::Error) -> String {
539        match self {
540            Self::DeadlineExceeded { seconds } => format!(
541                "{subject} exceeded its {seconds}s execution deadline \
542                 (capabilities.limits.deadline_seconds) and was interrupted"
543            ),
544            Self::ResourceLimit { message } => {
545                format!("{subject} exceeded its configured resource limits: {message}")
546            }
547            Self::Other => format!("{subject} trapped: {error}"),
548        }
549    }
550
551    /// [`Self::message`] for hook `hook`'s `fn_name` call. A deadline names the key on the hook's
552    /// own `artifacts:` entry, `entry`, which sets that hook's deadline ahead of the capsule-wide
553    /// one.
554    pub(crate) fn hook_message(
555        &self,
556        hook: &str,
557        entry: &str,
558        fn_name: &str,
559        error: &wasmtime::Error,
560    ) -> String {
561        let subject = format!("hook '{hook}' {fn_name}");
562        match self {
563            Self::DeadlineExceeded { seconds } => format!(
564                "{subject} exceeded its {seconds}s execution deadline \
565                 (capabilities.limits.deadline_seconds on the artifacts: entry '{entry}') and was \
566                 interrupted"
567            ),
568            _ => self.message(&subject, error),
569        }
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    #[test]
578    fn resolve_uses_defaults_when_manifest_is_silent() {
579        assert_eq!(ExecutionLimits::resolve(None), ExecutionLimits::default());
580    }
581
582    #[test]
583    fn resolve_fills_each_omitted_field_independently() {
584        let declared = murmur_artifact::ResourceLimits {
585            memory_bytes: Some(1024),
586            table_elements: None,
587            instances: None,
588            deadline_seconds: Some(5),
589        };
590        let limits = ExecutionLimits::resolve(Some(&declared));
591
592        assert_eq!(limits.memory_bytes, 1024);
593        assert_eq!(limits.deadline_seconds, 5);
594        assert_eq!(limits.table_elements, DEFAULT_TABLE_ELEMENTS);
595        assert_eq!(limits.instances, DEFAULT_INSTANCES);
596    }
597
598    /// A hook call gets the lower default when the manifest declared no deadline, while every
599    /// resource cap stays exactly what the capsule-wide resolution produced.
600    #[test]
601    fn hook_calls_get_the_lower_deadline_default_when_none_is_declared() {
602        let capsule = ExecutionLimits::resolve(None);
603        let hook = capsule.for_hook_calls(None);
604
605        assert_eq!(capsule.deadline_seconds, DEFAULT_DEADLINE_SECONDS);
606        assert_eq!(hook.deadline_seconds, HOOK_DEFAULT_DEADLINE_SECONDS);
607        assert_eq!(hook.memory_bytes, capsule.memory_bytes);
608        assert_eq!(hook.table_elements, capsule.table_elements);
609        assert_eq!(hook.instances, capsule.instances);
610    }
611
612    /// An explicitly declared deadline wins for hooks too — including one that happens to
613    /// equal the capsule-wide default, which is why resolution keys on the undefaulted
614    /// `Option` rather than on the resolved value.
615    #[test]
616    fn an_explicit_deadline_is_honored_for_hook_calls() {
617        let declared = murmur_artifact::ResourceLimits {
618            memory_bytes: None,
619            table_elements: None,
620            instances: None,
621            deadline_seconds: Some(DEFAULT_DEADLINE_SECONDS),
622        };
623        let capsule = ExecutionLimits::resolve(Some(&declared));
624
625        assert_eq!(
626            capsule
627                .for_hook_calls(declared.deadline_seconds)
628                .deadline_seconds,
629            DEFAULT_DEADLINE_SECONDS
630        );
631        assert_eq!(capsule.for_hook_calls(Some(5)).deadline_seconds, 5);
632    }
633
634    #[test]
635    fn deadline_ticks_converts_seconds_and_never_yields_zero() {
636        let limits = ExecutionLimits {
637            deadline_seconds: 1,
638            ..ExecutionLimits::default()
639        };
640        // 1s / 100ms per tick.
641        assert_eq!(limits.deadline_ticks(), 10);
642        assert_eq!(ExecutionLimits::default().deadline_ticks(), 6_000);
643    }
644
645    /// A limiter that has denied a growth request classifies as `ResourceLimit` even though
646    /// the resulting error is a plain message with no `Trap` to downcast to — the case the
647    /// recorded-denial design exists to cover.
648    #[test]
649    fn classify_reports_resource_limit_after_a_denial() {
650        let limits = ExecutionLimits {
651            memory_bytes: 1024,
652            ..ExecutionLimits::default()
653        };
654        let mut limiter = limits.limiter();
655        let error = limiter
656            .memory_growing(0, 4096, None)
657            .expect_err("growth past memory_bytes must be denied");
658
659        match classify_guest_failure(&error, &limiter) {
660            GuestFailure::ResourceLimit { message } => {
661                assert!(message.contains("capabilities.limits.memory_bytes"));
662            }
663            other => panic!("expected ResourceLimit, got {other:?}"),
664        }
665    }
666
667    #[test]
668    fn classify_reports_deadline_for_an_interrupt_trap() {
669        let limits = ExecutionLimits {
670            deadline_seconds: 7,
671            ..ExecutionLimits::default()
672        };
673        let limiter = limits.limiter();
674        let error = wasmtime::Error::from(wasmtime::Trap::Interrupt);
675
676        assert_eq!(
677            classify_guest_failure(&error, &limiter),
678            GuestFailure::DeadlineExceeded { seconds: 7 }
679        );
680    }
681
682    #[test]
683    fn a_hook_deadline_names_the_key_on_the_hooks_entry() {
684        let error = wasmtime::Error::from(wasmtime::Trap::Interrupt);
685        let failure = GuestFailure::DeadlineExceeded { seconds: 2 };
686        assert_eq!(
687            failure.hook_message("compact", "local/compact", "on-compaction", &error),
688            "hook 'compact' on-compaction exceeded its 2s execution deadline \
689             (capabilities.limits.deadline_seconds on the artifacts: entry 'local/compact') and \
690             was interrupted"
691        );
692        assert_eq!(
693            failure.message("tool 'echo'", &error),
694            "tool 'echo' exceeded its 2s execution deadline (capabilities.limits.deadline_seconds) \
695             and was interrupted",
696            "every other guest keeps the capsule-wide key"
697        );
698        assert_eq!(
699            GuestFailure::Other.hook_message("h", "local/h", "on-stage", &error),
700            GuestFailure::Other.message("hook 'h' on-stage", &error)
701        );
702    }
703
704    /// An ordinary guest panic must stay in the generic bucket, and must keep the exact
705    /// pre-slice wording on the string-returning paths.
706    #[test]
707    fn classify_reports_other_for_an_unrelated_trap() {
708        let limiter = ExecutionLimits::default().limiter();
709        let error = wasmtime::Error::from(wasmtime::Trap::UnreachableCodeReached);
710
711        let failure = classify_guest_failure(&error, &limiter);
712        assert_eq!(failure, GuestFailure::Other);
713        assert!(failure
714            .message("tool 'echo'", &error)
715            .starts_with("tool 'echo' trapped: "));
716    }
717
718    #[test]
719    fn the_wall_bound_is_the_deadline_plus_a_positive_grace() {
720        assert!(WALL_DEADLINE_GRACE > Duration::ZERO);
721        let limits = ExecutionLimits {
722            deadline_seconds: 3,
723            ..ExecutionLimits::default()
724        };
725        assert_eq!(
726            limits.wall_bound(),
727            Duration::from_secs(3) + WALL_DEADLINE_GRACE
728        );
729    }
730
731    #[tokio::test]
732    async fn bound_call_returns_a_finished_futures_output() {
733        assert_eq!(
734            bound_call(Duration::from_millis(50), async { 7 }).await,
735            Ok(7)
736        );
737    }
738
739    #[tokio::test]
740    async fn bound_call_stops_a_pending_future_after_the_bound() {
741        let bound = Duration::from_millis(30);
742        let cut = bound_call(bound, std::future::pending::<()>())
743            .await
744            .expect_err("a future that never finishes must be stopped");
745        assert!(cut.elapsed >= bound, "stopped after {:?}", cut.elapsed);
746    }
747
748    /// A firing is recorded once with what the call was, and a call that finished in time
749    /// records nothing.
750    #[tokio::test]
751    async fn the_latch_records_one_entry_per_firing() {
752        let deadlines = CallDeadlines::default();
753        let bound = (3, Duration::from_millis(30));
754        let finished = deadlines
755            .bound_within("echo", ComponentRole::Tool, "run", bound, async { 1 })
756            .await;
757        assert_eq!(finished, Ok(1));
758        assert!(deadlines.take().is_empty());
759
760        let cut = deadlines
761            .bound_within(
762                "stall",
763                ComponentRole::Driver,
764                "run",
765                bound,
766                std::future::pending::<()>(),
767            )
768            .await;
769        assert!(cut.is_err());
770        let taken = deadlines.take();
771        assert_eq!(taken.len(), 1);
772        assert_eq!(taken[0].component, "stall");
773        assert_eq!(taken[0].role.wire_name(), "driver");
774        assert_eq!(taken[0].call, "run");
775        assert_eq!(taken[0].deadline_seconds, 3);
776        assert!(taken[0].elapsed_ms >= 30);
777        assert!(deadlines.take().is_empty(), "a take clears the latch");
778    }
779
780    #[test]
781    fn wall_deadline_messages_name_the_key_that_set_the_deadline() {
782        assert_eq!(
783            wall_deadline_message("tool 'stall'", 3),
784            "tool 'stall' waited on the host past its 3s execution deadline \
785             (capabilities.limits.deadline_seconds) and was stopped"
786        );
787        assert_eq!(
788            wall_deadline_hook_message("sleep", "local/sleep", "on-session-start", 2),
789            "hook 'sleep' on-session-start waited on the host past its 2s execution deadline \
790             (capabilities.limits.deadline_seconds on the artifacts: entry 'local/sleep') and was \
791             stopped"
792        );
793    }
794
795    #[test]
796    fn limiter_allows_growth_within_the_cap() {
797        let limits = ExecutionLimits {
798            memory_bytes: 4096,
799            ..ExecutionLimits::default()
800        };
801        let mut limiter = limits.limiter();
802
803        assert!(limiter.memory_growing(0, 4096, None).unwrap());
804        assert!(limiter.denial().is_none());
805    }
806}