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)]
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)]
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)]
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    /// Takes every firing recorded since the last take, oldest first.
447    pub(crate) fn take(&self) -> Vec<CallDeadline> {
448        std::mem::take(&mut *self.lock())
449    }
450
451    /// Records `cut` as if a bound had fired, for a test of what drains the latch.
452    #[cfg(test)]
453    pub(crate) fn push_for_test(&self, cut: CallDeadline) {
454        self.lock().push(cut);
455    }
456
457    fn lock(&self) -> std::sync::MutexGuard<'_, Vec<CallDeadline>> {
458        self.0
459            .lock()
460            .unwrap_or_else(std::sync::PoisonError::into_inner)
461    }
462}
463
464/// The failure text of a call `subject` the wall-clock bound stopped, on the paths that report
465/// failures as plain text. The epoch's wording is [`GuestFailure::message`].
466pub(crate) fn wall_deadline_message(subject: &str, seconds: u64) -> String {
467    format!(
468        "{subject} waited on the host past its {seconds}s execution deadline \
469         (capabilities.limits.deadline_seconds) and was stopped"
470    )
471}
472
473/// [`wall_deadline_message`] for hook `hook`'s `fn_name` call, naming the key on the hook's own
474/// `artifacts:` entry, `entry`, as [`GuestFailure::hook_message`] does.
475pub(crate) fn wall_deadline_hook_message(
476    hook: &str,
477    entry: &str,
478    fn_name: &str,
479    seconds: u64,
480) -> String {
481    format!(
482        "hook '{hook}' {fn_name} waited on the host past its {seconds}s execution deadline \
483         (capabilities.limits.deadline_seconds on the artifacts: entry '{entry}') and was stopped"
484    )
485}
486
487/// Why a guest invocation failed, separating the two limits this module enforces from an
488/// ordinary trap.
489#[derive(Debug, Clone, PartialEq, Eq)]
490pub(crate) enum GuestFailure {
491    /// The epoch deadline fired — the guest ran longer than `deadline_seconds`.
492    DeadlineExceeded { seconds: u64 },
493    /// The store's resource limiter denied a growth request.
494    ResourceLimit { message: String },
495    /// Anything else: guest panic, `unreachable`, host error.
496    Other,
497}
498
499/// The single classification point for every failed guest invocation — capsule `run`,
500/// tool/driver `run`, and hook lifecycle calls all route their `Err` through here so the
501/// three outcomes stay distinguishable without each call site re-deriving the logic.
502///
503/// A limiter denial is checked first and wins over any trap code, because the denial is
504/// recorded at the moment of refusal and is therefore authoritative about the cause; the
505/// error wasmtime surfaces afterwards may have been reshaped on its way out of the guest.
506pub(crate) fn classify_guest_failure(
507    error: &wasmtime::Error,
508    limiter: &ExecutionLimiter,
509) -> GuestFailure {
510    if let Some(message) = limiter.denial() {
511        return GuestFailure::ResourceLimit {
512            message: message.to_string(),
513        };
514    }
515    if matches!(
516        error.downcast_ref::<wasmtime::Trap>(),
517        Some(wasmtime::Trap::Interrupt)
518    ) {
519        return GuestFailure::DeadlineExceeded {
520            seconds: limiter.limits().deadline_seconds,
521        };
522    }
523    GuestFailure::Other
524}
525
526impl GuestFailure {
527    /// Render this failure for the `String`-returning tool/driver and hook paths, which
528    /// report failures as plain text rather than a [`crate::errors::RuntimeError`].
529    ///
530    /// `subject` names the guest, e.g. `tool 'echo'` or `hook 'audit'`. The [`Self::Other`]
531    /// wording is the pre-existing generic phrasing, kept verbatim so only the two limit
532    /// cases read differently.
533    pub(crate) fn message(&self, subject: &str, error: &wasmtime::Error) -> String {
534        match self {
535            Self::DeadlineExceeded { seconds } => format!(
536                "{subject} exceeded its {seconds}s execution deadline \
537                 (capabilities.limits.deadline_seconds) and was interrupted"
538            ),
539            Self::ResourceLimit { message } => {
540                format!("{subject} exceeded its configured resource limits: {message}")
541            }
542            Self::Other => format!("{subject} trapped: {error}"),
543        }
544    }
545
546    /// [`Self::message`] for hook `hook`'s `fn_name` call. A deadline names the key on the hook's
547    /// own `artifacts:` entry, `entry`, which sets that hook's deadline ahead of the capsule-wide
548    /// one.
549    pub(crate) fn hook_message(
550        &self,
551        hook: &str,
552        entry: &str,
553        fn_name: &str,
554        error: &wasmtime::Error,
555    ) -> String {
556        let subject = format!("hook '{hook}' {fn_name}");
557        match self {
558            Self::DeadlineExceeded { seconds } => format!(
559                "{subject} exceeded its {seconds}s execution deadline \
560                 (capabilities.limits.deadline_seconds on the artifacts: entry '{entry}') and was \
561                 interrupted"
562            ),
563            _ => self.message(&subject, error),
564        }
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    #[test]
573    fn resolve_uses_defaults_when_manifest_is_silent() {
574        assert_eq!(ExecutionLimits::resolve(None), ExecutionLimits::default());
575    }
576
577    #[test]
578    fn resolve_fills_each_omitted_field_independently() {
579        let declared = murmur_artifact::ResourceLimits {
580            memory_bytes: Some(1024),
581            table_elements: None,
582            instances: None,
583            deadline_seconds: Some(5),
584        };
585        let limits = ExecutionLimits::resolve(Some(&declared));
586
587        assert_eq!(limits.memory_bytes, 1024);
588        assert_eq!(limits.deadline_seconds, 5);
589        assert_eq!(limits.table_elements, DEFAULT_TABLE_ELEMENTS);
590        assert_eq!(limits.instances, DEFAULT_INSTANCES);
591    }
592
593    /// A hook call gets the lower default when the manifest declared no deadline, while every
594    /// resource cap stays exactly what the capsule-wide resolution produced.
595    #[test]
596    fn hook_calls_get_the_lower_deadline_default_when_none_is_declared() {
597        let capsule = ExecutionLimits::resolve(None);
598        let hook = capsule.for_hook_calls(None);
599
600        assert_eq!(capsule.deadline_seconds, DEFAULT_DEADLINE_SECONDS);
601        assert_eq!(hook.deadline_seconds, HOOK_DEFAULT_DEADLINE_SECONDS);
602        assert_eq!(hook.memory_bytes, capsule.memory_bytes);
603        assert_eq!(hook.table_elements, capsule.table_elements);
604        assert_eq!(hook.instances, capsule.instances);
605    }
606
607    /// An explicitly declared deadline wins for hooks too — including one that happens to
608    /// equal the capsule-wide default, which is why resolution keys on the undefaulted
609    /// `Option` rather than on the resolved value.
610    #[test]
611    fn an_explicit_deadline_is_honored_for_hook_calls() {
612        let declared = murmur_artifact::ResourceLimits {
613            memory_bytes: None,
614            table_elements: None,
615            instances: None,
616            deadline_seconds: Some(DEFAULT_DEADLINE_SECONDS),
617        };
618        let capsule = ExecutionLimits::resolve(Some(&declared));
619
620        assert_eq!(
621            capsule
622                .for_hook_calls(declared.deadline_seconds)
623                .deadline_seconds,
624            DEFAULT_DEADLINE_SECONDS
625        );
626        assert_eq!(capsule.for_hook_calls(Some(5)).deadline_seconds, 5);
627    }
628
629    #[test]
630    fn deadline_ticks_converts_seconds_and_never_yields_zero() {
631        let limits = ExecutionLimits {
632            deadline_seconds: 1,
633            ..ExecutionLimits::default()
634        };
635        // 1s / 100ms per tick.
636        assert_eq!(limits.deadline_ticks(), 10);
637        assert_eq!(ExecutionLimits::default().deadline_ticks(), 6_000);
638    }
639
640    /// A limiter that has denied a growth request classifies as `ResourceLimit` even though
641    /// the resulting error is a plain message with no `Trap` to downcast to — the case the
642    /// recorded-denial design exists to cover.
643    #[test]
644    fn classify_reports_resource_limit_after_a_denial() {
645        let limits = ExecutionLimits {
646            memory_bytes: 1024,
647            ..ExecutionLimits::default()
648        };
649        let mut limiter = limits.limiter();
650        let error = limiter
651            .memory_growing(0, 4096, None)
652            .expect_err("growth past memory_bytes must be denied");
653
654        match classify_guest_failure(&error, &limiter) {
655            GuestFailure::ResourceLimit { message } => {
656                assert!(message.contains("capabilities.limits.memory_bytes"));
657            }
658            other => panic!("expected ResourceLimit, got {other:?}"),
659        }
660    }
661
662    #[test]
663    fn classify_reports_deadline_for_an_interrupt_trap() {
664        let limits = ExecutionLimits {
665            deadline_seconds: 7,
666            ..ExecutionLimits::default()
667        };
668        let limiter = limits.limiter();
669        let error = wasmtime::Error::from(wasmtime::Trap::Interrupt);
670
671        assert_eq!(
672            classify_guest_failure(&error, &limiter),
673            GuestFailure::DeadlineExceeded { seconds: 7 }
674        );
675    }
676
677    #[test]
678    fn a_hook_deadline_names_the_key_on_the_hooks_entry() {
679        let error = wasmtime::Error::from(wasmtime::Trap::Interrupt);
680        let failure = GuestFailure::DeadlineExceeded { seconds: 2 };
681        assert_eq!(
682            failure.hook_message("compact", "local/compact", "on-compaction", &error),
683            "hook 'compact' on-compaction exceeded its 2s execution deadline \
684             (capabilities.limits.deadline_seconds on the artifacts: entry 'local/compact') and \
685             was interrupted"
686        );
687        assert_eq!(
688            failure.message("tool 'echo'", &error),
689            "tool 'echo' exceeded its 2s execution deadline (capabilities.limits.deadline_seconds) \
690             and was interrupted",
691            "every other guest keeps the capsule-wide key"
692        );
693        assert_eq!(
694            GuestFailure::Other.hook_message("h", "local/h", "on-stage", &error),
695            GuestFailure::Other.message("hook 'h' on-stage", &error)
696        );
697    }
698
699    /// An ordinary guest panic must stay in the generic bucket, and must keep the exact
700    /// pre-slice wording on the string-returning paths.
701    #[test]
702    fn classify_reports_other_for_an_unrelated_trap() {
703        let limiter = ExecutionLimits::default().limiter();
704        let error = wasmtime::Error::from(wasmtime::Trap::UnreachableCodeReached);
705
706        let failure = classify_guest_failure(&error, &limiter);
707        assert_eq!(failure, GuestFailure::Other);
708        assert!(failure
709            .message("tool 'echo'", &error)
710            .starts_with("tool 'echo' trapped: "));
711    }
712
713    #[test]
714    fn the_wall_bound_is_the_deadline_plus_a_positive_grace() {
715        assert!(WALL_DEADLINE_GRACE > Duration::ZERO);
716        let limits = ExecutionLimits {
717            deadline_seconds: 3,
718            ..ExecutionLimits::default()
719        };
720        assert_eq!(
721            limits.wall_bound(),
722            Duration::from_secs(3) + WALL_DEADLINE_GRACE
723        );
724    }
725
726    #[tokio::test]
727    async fn bound_call_returns_a_finished_futures_output() {
728        assert_eq!(
729            bound_call(Duration::from_millis(50), async { 7 }).await,
730            Ok(7)
731        );
732    }
733
734    #[tokio::test]
735    async fn bound_call_stops_a_pending_future_after_the_bound() {
736        let bound = Duration::from_millis(30);
737        let cut = bound_call(bound, std::future::pending::<()>())
738            .await
739            .expect_err("a future that never finishes must be stopped");
740        assert!(cut.elapsed >= bound, "stopped after {:?}", cut.elapsed);
741    }
742
743    /// A firing is recorded once with what the call was, and a call that finished in time
744    /// records nothing.
745    #[tokio::test]
746    async fn the_latch_records_one_entry_per_firing() {
747        let deadlines = CallDeadlines::default();
748        let bound = (3, Duration::from_millis(30));
749        let finished = deadlines
750            .bound_within("echo", ComponentRole::Tool, "run", bound, async { 1 })
751            .await;
752        assert_eq!(finished, Ok(1));
753        assert!(deadlines.take().is_empty());
754
755        let cut = deadlines
756            .bound_within(
757                "stall",
758                ComponentRole::Driver,
759                "run",
760                bound,
761                std::future::pending::<()>(),
762            )
763            .await;
764        assert!(cut.is_err());
765        let taken = deadlines.take();
766        assert_eq!(taken.len(), 1);
767        assert_eq!(taken[0].component, "stall");
768        assert_eq!(taken[0].role.wire_name(), "driver");
769        assert_eq!(taken[0].call, "run");
770        assert_eq!(taken[0].deadline_seconds, 3);
771        assert!(taken[0].elapsed_ms >= 30);
772        assert!(deadlines.take().is_empty(), "a take clears the latch");
773    }
774
775    #[test]
776    fn wall_deadline_messages_name_the_key_that_set_the_deadline() {
777        assert_eq!(
778            wall_deadline_message("tool 'stall'", 3),
779            "tool 'stall' waited on the host past its 3s execution deadline \
780             (capabilities.limits.deadline_seconds) and was stopped"
781        );
782        assert_eq!(
783            wall_deadline_hook_message("sleep", "local/sleep", "on-session-start", 2),
784            "hook 'sleep' on-session-start waited on the host past its 2s execution deadline \
785             (capabilities.limits.deadline_seconds on the artifacts: entry 'local/sleep') and was \
786             stopped"
787        );
788    }
789
790    #[test]
791    fn limiter_allows_growth_within_the_cap() {
792        let limits = ExecutionLimits {
793            memory_bytes: 4096,
794            ..ExecutionLimits::default()
795        };
796        let mut limiter = limits.limiter();
797
798        assert!(limiter.memory_growing(0, 4096, None).unwrap());
799        assert!(limiter.denial().is_none());
800    }
801}