1use 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
43pub(crate) const EPOCH_TICK_INTERVAL: Duration = Duration::from_millis(100);
46
47pub const DEFAULT_MEMORY_BYTES: usize = 512 * 1024 * 1024;
50
51pub const DEFAULT_TABLE_ELEMENTS: usize = 100_000;
53
54pub const DEFAULT_INSTANCES: usize = 1_000;
58
59pub(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
71pub const DEFAULT_DEADLINE_SECONDS: u64 = 600;
78
79pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub struct ExecutionLimits {
98 pub memory_bytes: usize,
100 pub table_elements: usize,
102 pub instances: usize,
104 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 #[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 #[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 #[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 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 pub(crate) fn wall_bound(&self) -> Duration {
177 Duration::from_secs(self.deadline_seconds).saturating_add(WALL_DEADLINE_GRACE)
178 }
179
180 pub(crate) fn limiter(&self) -> ExecutionLimiter {
182 ExecutionLimiter::new(*self)
183 }
184}
185
186pub(crate) struct ExecutionLimiter {
198 inner: StoreLimits,
199 limits: ExecutionLimits,
200 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 pub(crate) fn limits(&self) -> ExecutionLimits {
221 self.limits
222 }
223
224 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
280pub(crate) struct EpochTicker {
286 stop: Arc<AtomicBool>,
287}
288
289impl EpochTicker {
290 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 fn drop(&mut self) {
325 self.stop.store(true, Ordering::Relaxed);
326 }
327}
328
329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
331pub(crate) struct WallDeadline {
332 pub(crate) elapsed: Duration,
333}
334
335pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub(crate) enum ComponentRole {
355 Capsule,
357 Driver,
360 Tool,
362 Hook,
363 ProcessDriver,
364}
365
366impl ComponentRole {
367 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#[derive(Debug, Clone, PartialEq, Eq)]
381pub(crate) struct CallDeadline {
382 pub(crate) component: String,
384 pub(crate) role: ComponentRole,
385 pub(crate) call: String,
388 pub(crate) deadline_seconds: u64,
389 pub(crate) elapsed_ms: u64,
390}
391
392#[derive(Debug, Clone, Default)]
401pub(crate) struct CallDeadlines(Arc<Mutex<Vec<CallDeadline>>>);
402
403impl CallDeadlines {
404 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 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 pub(crate) fn take(&self) -> Vec<CallDeadline> {
448 std::mem::take(&mut *self.lock())
449 }
450
451 #[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
464pub(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
473pub(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#[derive(Debug, Clone, PartialEq, Eq)]
490pub(crate) enum GuestFailure {
491 DeadlineExceeded { seconds: u64 },
493 ResourceLimit { message: String },
495 Other,
497}
498
499pub(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 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 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 #[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 #[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 assert_eq!(limits.deadline_ticks(), 10);
637 assert_eq!(ExecutionLimits::default().deadline_ticks(), 6_000);
638 }
639
640 #[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 #[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 #[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}