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