1use std::collections::HashMap;
2use std::future::Future;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::time::Duration;
6
7use serde_json::Value;
8use wasmtime::component::{Component, Linker, ResourceTable};
9use wasmtime::{AsContextMut, Config, Engine, Store};
10use wasmtime_wasi::WasiCtxBuilder;
11
12use camel_api::{Body, Exchange};
13use camel_core::Registry;
14use tokio::sync::Notify;
15use tokio_util::sync::CancellationToken;
16
17use crate::bindings::Plugin;
18use crate::bindings::camel::plugin::types::WasmExchange;
19use crate::error::WasmError;
20use crate::return_stream::{DrainReceiver, spawn_return_drain, take_stream_handoff_sender};
21
22pub struct WasmHostState {
23 pub table: ResourceTable,
24 pub wasi: wasmtime_wasi::WasiCtx,
25 pub properties: HashMap<String, Value>,
26 pub registry: Arc<std::sync::Mutex<Registry>>,
27 pub call_depth: Arc<std::sync::atomic::AtomicUsize>,
28 pub limits: wasmtime::StoreLimits,
29 pub state_store: crate::state_store::StateStore,
30 pub capabilities: crate::capabilities::WasmCapabilities,
31}
32
33impl wasmtime_wasi::WasiView for WasmHostState {
34 fn ctx(&mut self) -> wasmtime_wasi::WasiCtxView<'_> {
35 wasmtime_wasi::WasiCtxView {
36 ctx: &mut self.wasi,
37 table: &mut self.table,
38 }
39 }
40}
41
42pub struct WasmRuntime {
43 engine: Engine,
44 linker: Linker<WasmHostState>,
45 component: Component,
46 module_path: PathBuf,
47 config: crate::config::WasmConfig,
48 #[allow(dead_code)]
49 epoch_ticker: crate::epoch::EpochTicker,
50}
51
52pub struct StreamingResult {
58 pub exchange: WasmExchange,
59 pub(crate) drain_rx: Option<DrainReceiver>,
60 pub(crate) metadata: camel_api::StreamMetadata,
61}
62
63impl WasmRuntime {
64 pub async fn new(
65 module_path: impl AsRef<Path>,
66 wasm_config: crate::config::WasmConfig,
67 ) -> Result<Self, WasmError> {
68 let module_path = module_path.as_ref().to_path_buf();
69
70 let mut config = Config::new();
71 config.wasm_component_model(true);
72 config.epoch_interruption(true);
73 config.concurrency_support(true);
74
75 let engine =
76 Engine::new(&config).map_err(|e| WasmError::CompilationFailed(e.to_string()))?;
77
78 if !module_path.exists() {
80 return Err(WasmError::ModuleNotFound(format!(
81 "Failed to load WASM module {}: not found",
82 module_path.display()
83 )));
84 }
85
86 crate::config::validate_wasm_size(&module_path, wasm_config.max_wasm_size_bytes)
88 .map_err(WasmError::CompilationFailed)?;
89
90 let component = Component::from_file(&engine, &module_path).map_err(|e| {
91 WasmError::CompilationFailed(format!(
93 "Failed to load WASM module {}: {}",
94 module_path.display(),
95 e
96 ))
97 })?;
98
99 let mut linker: Linker<WasmHostState> = Linker::new(&engine);
100
101 wasmtime_wasi::p2::add_to_linker_async(&mut linker)
102 .map_err(|e| WasmError::CompilationFailed(e.to_string()))?;
103
104 crate::host_functions::add_to_linker(&mut linker)
105 .map_err(|e| WasmError::CompilationFailed(e.to_string()))?;
106
107 let epoch_ticker =
108 crate::epoch::EpochTicker::start(engine.clone(), wasm_config.epoch_interval());
109
110 Ok(Self {
111 engine,
112 linker,
113 component,
114 module_path,
115 config: wasm_config,
116 epoch_ticker,
117 })
118 }
119
120 #[allow(clippy::too_many_arguments)] pub fn create_host_state(
133 registry: Arc<std::sync::Mutex<Registry>>,
134 properties: HashMap<String, Value>,
135 state_store: crate::state_store::StateStore,
136 max_memory_bytes: u64,
137 max_instances: usize,
138 max_tables: usize,
139 max_table_elements: Option<usize>,
140 capabilities: crate::capabilities::WasmCapabilities,
141 ) -> WasmHostState {
142 let mut builder = wasmtime::StoreLimitsBuilder::new();
143 if max_memory_bytes > 0 {
144 builder = builder.memory_size(max_memory_bytes as usize);
145 }
146 builder = builder.instances(max_instances);
147 builder = builder.tables(max_tables);
148 if let Some(te) = max_table_elements {
149 builder = builder.table_elements(te);
150 }
151 let limits = builder.build();
152 WasmHostState {
153 table: ResourceTable::new(),
154 wasi: WasiCtxBuilder::new().inherit_stderr().build(),
155 properties,
156 registry,
157 call_depth: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
158 limits,
159 state_store,
160 capabilities,
161 }
162 }
163
164 fn classify_error(&self, e: wasmtime::Error) -> WasmError {
169 self.config.classify_error(&self.module_path, e)
170 }
171
172 pub async fn call_init_once(
173 &self,
174 registry: Arc<std::sync::Mutex<Registry>>,
175 properties: HashMap<String, Value>,
176 state_store: crate::state_store::StateStore,
177 ) -> Result<(), WasmError> {
178 let host_state = Self::create_host_state(
179 registry,
180 properties,
181 state_store,
182 self.config.max_memory_bytes,
183 self.config.max_instances,
184 self.config.max_tables,
185 self.config.max_table_elements,
186 crate::capabilities::WasmCapabilities::from_scheme_list(
187 &self.config.allow_call_schemes,
188 ),
189 );
190 let mut store = Store::new(&self.engine, host_state);
191 store.limiter(|state| &mut state.limits);
192 store.set_epoch_deadline(self.config.epoch_deadline());
193
194 let plugin = Plugin::instantiate_async(&mut store, &self.component, &self.linker)
195 .await
196 .map_err(|e| WasmError::InstantiationFailed(e.to_string()))?;
197
198 let result: Result<(), String> = crate::error::peel_concurrent(
204 store
205 .as_context_mut()
206 .run_concurrent(async |accessor| plugin.call_init(accessor).await)
207 .await,
208 |e| self.classify_error(e),
209 |e| self.classify_error(e),
210 )?;
211
212 if let Err(e) = result {
213 tracing::debug!(
214 "WASM init() returned error (optional hook): {} — {}",
215 self.module_path.display(),
216 e
217 );
218 }
219 Ok(())
220 }
221
222 pub async fn call_process(
223 &self,
224 registry: Arc<std::sync::Mutex<Registry>>,
225 properties: HashMap<String, Value>,
226 state_store: crate::state_store::StateStore,
227 exchange: WasmExchange,
228 ) -> Result<WasmExchange, WasmError> {
229 let host_state = Self::create_host_state(
230 registry,
231 properties,
232 state_store,
233 self.config.max_memory_bytes,
234 self.config.max_instances,
235 self.config.max_tables,
236 self.config.max_table_elements,
237 crate::capabilities::WasmCapabilities::from_scheme_list(
238 &self.config.allow_call_schemes,
239 ),
240 );
241 let mut store = Store::new(&self.engine, host_state);
242 store.limiter(|state| &mut state.limits);
243 store.set_epoch_deadline(self.config.epoch_deadline());
244
245 let plugin = Plugin::instantiate_async(&mut store, &self.component, &self.linker)
246 .await
247 .map_err(|e| WasmError::InstantiationFailed(e.to_string()))?;
248
249 let result: Result<WasmExchange, crate::bindings::camel::plugin::types::WasmError> =
254 crate::error::peel_concurrent(
255 store
256 .as_context_mut()
257 .run_concurrent(async |accessor| plugin.call_process(accessor, exchange).await)
258 .await,
259 |e| self.classify_error(e),
260 |e| self.classify_error(e),
261 )?;
262
263 result.map_err(crate::error::map_plugin_error)
264 }
265
266 #[allow(clippy::too_many_arguments)] pub async fn process_streaming_exchange(
298 &self,
299 registry: Arc<std::sync::Mutex<Registry>>,
300 properties: HashMap<String, Value>,
301 state_store: crate::state_store::StateStore,
302 exchange: Exchange,
303 pending_permit: tokio::sync::OwnedSemaphorePermit,
304 cancel: CancellationToken,
305 max_bytes: u64,
306 no_progress_timeout: Duration,
307 ) -> Result<StreamingResult, WasmError> {
308 let host_state = Self::create_host_state(
309 registry,
310 properties,
311 state_store,
312 self.config.max_memory_bytes,
313 self.config.max_instances,
314 self.config.max_tables,
315 self.config.max_table_elements,
316 crate::capabilities::WasmCapabilities::from_scheme_list(
317 &self.config.allow_call_schemes,
318 ),
319 );
320 let mut store = Store::new(&self.engine, host_state);
321 store.limiter(|state| &mut state.limits);
322 store.set_epoch_deadline(self.config.epoch_deadline());
323
324 let plugin = Plugin::instantiate_async(&mut store, &self.component, &self.linker)
325 .await
326 .map_err(|e| WasmError::InstantiationFailed(e.to_string()))?;
327
328 let mut exchange = exchange;
333 let taken_body = std::mem::replace(&mut exchange.input.body, Body::Empty);
334 let mut stream_parts = match taken_body {
335 Body::Stream(stream_body) => {
336 let (stream, metadata) =
337 crate::stream_bridge::extract_stream_body(stream_body).await;
338 Some((stream, metadata))
339 }
340 other => {
341 exchange.input.body = other;
342 None
343 }
344 };
345
346 let classify_config = self.config.clone(); let classify_module_path = self.module_path.clone(); let invoke_stall_timeout = stream_parts
356 .as_ref()
357 .map(|_| Duration::from_secs(self.config.timeout_secs));
358
359 let (exchange_out, drain_rx, metadata) = spawn_return_drain(
360 Some(pending_permit),
361 cancel,
362 no_progress_timeout,
363 invoke_stall_timeout,
364 None, move |handoff_shared, dtx, drx, drain_started, coord| async move {
368 let wx = crate::serde_bridge::exchange_to_wasm(&exchange)
373 .expect("exchange_to_wasm for stream-return path"); let handoff_drive = handoff_shared.clone();
376
377 let result: Result<(), WasmError> = async {
378 let nested = store
379 .as_context_mut()
380 .run_concurrent(async |accessor| {
381 let wasm_exchange = if let Some((stream_opt, metadata)) = stream_parts.take()
382 {
383 let body = match stream_opt {
384 Some(stream) => crate::stream_bridge::assemble_stream_body(
385 accessor,
386 stream,
387 &metadata,
388 coord.cancel.clone(),
389 max_bytes,
390 coord.progress.clone(),
391 )?,
392 None => {
393 return Err(wasmtime::Error::msg(
394 "wasm: stream body already consumed before guest invocation",
395 ));
396 }
397 };
398 crate::serde_bridge::exchange_to_wasm_with_body(&exchange, body)
399 .map_err(|e| wasmtime::Error::msg(e.to_string()))?
400 } else {
401 wx
402 };
403 let wasm_exchange_result = plugin.call_process(accessor, wasm_exchange).await?;
404 let mut wasm_exchange = match wasm_exchange_result {
405 Ok(exchange) => exchange,
406 Err(e) => {
407 return Err(wasmtime::Error::msg(format!("{e}")));
408 }
409 };
410 use crate::return_stream::StreamReturnable;
411 match wasm_exchange.take_stream() {
412 Some((reader, terminal_future, guest_metadata)) => {
413 drain_started.notify_one();
414 if let Some(tx) = take_stream_handoff_sender(&handoff_drive) {
415 let _ = tx.send(Ok((wasm_exchange, Some(crate::return_stream::DrainReceiver { rx: drx, terminal: coord.terminal_slot.clone() }), guest_metadata))); }
417 tokio::select! {
420 _ = crate::return_stream::drain_guest_stream(
421 accessor, reader, terminal_future, dtx,
422 coord.clone(),
423 ) => {}
424 _ = coord.receiver_gone.notified() => { coord.cancel.cancel(); }
425 }
426 }
427 None => {
428 if let Some(tx) = take_stream_handoff_sender(&handoff_drive) {
429 let _ = tx.send(Ok((wasm_exchange, None, camel_api::StreamMetadata::default())));
430 }
431 drop(drx);
432 drop(dtx); }
434 }
435 Ok(())
436 })
437 .await;
438 crate::error::peel_concurrent(
442 nested,
443 |e| {
444 crate::config::classify_error(&classify_config, &classify_module_path, e)
445 },
446 |e| WasmError::GuestPanic(format!("plugin process trapped: {e}")),
447 )
448 }
449 .await;
450
451 match &result {
454 Ok(()) => {}
455 Err(e) => {
456 if let Some(tx) = take_stream_handoff_sender(&handoff_drive) {
457 let _ = tx.send(Err(e.clone()));
458 }
459 }
460 }
461 result
462 },
463 )
464 .await?;
465
466 Ok(StreamingResult {
467 exchange: exchange_out,
468 drain_rx,
469 metadata,
470 })
471 }
472
473 pub(crate) async fn drive_with_drain_watchdog<F, T>(
481 run_fut: F,
482 progress_notify: &Notify,
483 drain_started: &Notify,
484 drain_timeout: Duration,
485 invoke_stall_timeout: Option<Duration>,
486 ) -> Result<T, WasmError>
487 where
488 F: Future<Output = Result<T, WasmError>>,
489 {
490 let mut run_fut = std::pin::pin!(run_fut);
491 match invoke_stall_timeout {
493 None => tokio::select! {
494 r = &mut run_fut => return r,
495 _ = drain_started.notified() => {}
496 },
497 Some(t) => loop {
498 tokio::select! {
499 r = &mut run_fut => return r,
500 _ = drain_started.notified() => break,
501 _ = progress_notify.notified() => continue,
502 _ = tokio::time::sleep(t) => {
503 return Err(WasmError::GuestPanic(
504 "wasm: invoke stalled — no input progress \
505 (upstream stalled or guest deadlocked)".into(),
506 ));
507 }
508 }
509 },
510 }
511 loop {
513 tokio::select! {
514 r = &mut run_fut => return r,
515 _ = progress_notify.notified() => continue,
516 _ = tokio::time::sleep(drain_timeout) => {
517 return Err(WasmError::GuestPanic(
518 "wasm: no-progress timeout (stream stalled)".into(),
519 ));
520 }
521 }
522 }
523 }
524
525 pub fn module_path(&self) -> &Path {
526 &self.module_path
527 }
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 #[test]
535 fn test_wasm_host_state_creation() {
536 let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
537 let props = HashMap::new();
538 let state = WasmHostState {
539 table: ResourceTable::new(),
540 wasi: WasiCtxBuilder::new().inherit_stderr().build(),
541 properties: props,
542 registry,
543 call_depth: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
544 limits: wasmtime::StoreLimits::default(),
545 state_store: crate::state_store::StateStore::new(),
546 capabilities: crate::capabilities::WasmCapabilities::default(),
547 };
548 assert!(state.properties.is_empty());
549 assert_eq!(
550 state.call_depth.load(std::sync::atomic::Ordering::Relaxed),
551 0
552 );
553 }
554
555 #[test]
556 fn create_host_state_with_zero_memory_falls_back_to_default() {
557 let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
560 let host_state = WasmRuntime::create_host_state(
561 registry,
562 HashMap::new(),
563 crate::state_store::StateStore::new(),
564 0,
565 10_000,
566 10_000,
567 None,
568 crate::capabilities::WasmCapabilities::default(),
569 );
570 let _ = host_state; }
572
573 #[tokio::test]
574 async fn create_host_state_zero_memory_still_applies_instance_caps() {
575 let wat = r#"
583 (module
584 (func (export "dummy"))
585 )
586 "#;
587 let mut config = wasmtime::Config::new();
588 config.wasm_component_model(true);
589 let engine = Engine::new(&config).unwrap();
590 let module = wasmtime::Module::new(&engine, wat).expect("compile wat");
591
592 let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
593 let host_state = WasmRuntime::create_host_state(
594 registry,
595 HashMap::new(),
596 crate::state_store::StateStore::new(),
597 0, 1, 1, None,
601 crate::capabilities::WasmCapabilities::default(),
602 );
603 let mut store = Store::new(&engine, host_state);
604 store.limiter(|state| &mut state.limits);
605
606 let _inst = wasmtime::Instance::new_async(&mut store, &module, &[])
608 .await
609 .expect("first instance must succeed");
610
611 let err = wasmtime::Instance::new_async(&mut store, &module, &[])
613 .await
614 .expect_err("second instance must be rejected by instance cap");
615 let msg = err.to_string();
616 assert!(
617 msg.contains("instance") || msg.contains("limit"),
618 "error must reference instance limit: {msg}"
619 );
620 }
621
622 #[tokio::test]
626 async fn memory_growth_rejected_past_configured_cap() {
627 let wat = r#"
637 (module
638 (memory $mem (export "memory") 1)
639 (func (export "grow") (param i32) (result i32)
640 local.get 0
641 memory.grow $mem)
642 )
643 "#;
644
645 let config = wasmtime::Config::new();
646 let engine = Engine::new(&config).unwrap();
647 let module = wasmtime::Module::new(&engine, wat).expect("compile wat");
648
649 let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
650 let host_state = WasmRuntime::create_host_state(
651 registry,
652 HashMap::new(),
653 crate::state_store::StateStore::new(),
654 64 * 1024, 10_000,
656 10_000,
657 None,
658 crate::capabilities::WasmCapabilities::default(),
659 );
660 let mut store = Store::new(&engine, host_state);
661 store.limiter(|state| &mut state.limits);
662
663 let instance = wasmtime::Instance::new_async(&mut store, &module, &[])
664 .await
665 .expect("instantiate");
666 let grow = instance
667 .get_typed_func::<i32, i32>(&mut store, "grow")
668 .expect("get grow export");
669
670 let result = grow.call_async(&mut store, 64).await.expect("grow call");
673 assert_eq!(
674 result, -1,
675 "memory.grow must return -1 when the cap (64 KiB) would be exceeded"
676 );
677 }
678
679 #[tokio::test]
680 async fn memory_growth_allowed_under_cap() {
681 let wat = r#"
685 (module
686 (memory $mem (export "memory") 1)
687 (func (export "grow") (param i32) (result i32)
688 local.get 0
689 memory.grow $mem)
690 )
691 "#;
692
693 let config = wasmtime::Config::new();
694 let engine = Engine::new(&config).unwrap();
695 let module = wasmtime::Module::new(&engine, wat).expect("compile wat");
696
697 let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
698 let host_state = WasmRuntime::create_host_state(
700 registry,
701 HashMap::new(),
702 crate::state_store::StateStore::new(),
703 128 * 1024,
704 10_000,
705 10_000,
706 None,
707 crate::capabilities::WasmCapabilities::default(),
708 );
709 let mut store = Store::new(&engine, host_state);
710 store.limiter(|state| &mut state.limits);
711
712 let instance = wasmtime::Instance::new_async(&mut store, &module, &[])
713 .await
714 .expect("instantiate");
715 let grow = instance
716 .get_typed_func::<i32, i32>(&mut store, "grow")
717 .expect("get grow export");
718
719 let result = grow.call_async(&mut store, 1).await.expect("grow call");
723 assert_eq!(result, 1, "memory.grow of 1 page under cap must succeed");
724 }
725
726 #[test]
727 fn test_host_state_has_limits_field() {
728 let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
729 let state = WasmRuntime::create_host_state(
730 registry,
731 HashMap::new(),
732 crate::state_store::StateStore::new(),
733 0,
734 10_000,
735 10_000,
736 None,
737 crate::capabilities::WasmCapabilities::default(),
738 );
739 let _limits: &wasmtime::StoreLimits = &state.limits;
740 }
741
742 #[test]
743 fn test_epoch_deadline_set_on_store() {
744 let mut config = wasmtime::Config::new();
745 config.epoch_interruption(true);
746 config.wasm_component_model(true);
747 let engine = Engine::new(&config).unwrap();
748 let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
749 let host_state = WasmRuntime::create_host_state(
750 registry,
751 HashMap::new(),
752 crate::state_store::StateStore::new(),
753 0,
754 10_000,
755 10_000,
756 None,
757 crate::capabilities::WasmCapabilities::default(),
758 );
759 let mut store = Store::new(&engine, host_state);
760 store.set_epoch_deadline(500);
761 }
766
767 #[test]
768 fn test_store_limiter_uses_host_state_limits() {
769 let mut config = wasmtime::Config::new();
770 config.epoch_interruption(true);
771 config.wasm_component_model(true);
772 let engine = Engine::new(&config).unwrap();
773 let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
774 let host_state = WasmRuntime::create_host_state(
775 registry,
776 HashMap::new(),
777 crate::state_store::StateStore::new(),
778 1024, 10_000,
780 10_000,
781 None,
782 crate::capabilities::WasmCapabilities::default(),
783 );
784 let mut store = Store::new(&engine, host_state);
785 store.limiter(|state| &mut state.limits);
786 }
789
790 #[test]
791 fn store_limits_default_no_table_elements_cap() {
792 let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
796 let state = WasmRuntime::create_host_state(
797 registry,
798 HashMap::new(),
799 crate::state_store::StateStore::new(),
800 50 * 1024 * 1024, 10_000,
802 10_000,
803 None,
804 crate::capabilities::WasmCapabilities::default(),
805 );
806 let _limits: &wasmtime::StoreLimits = &state.limits;
808 }
809
810 #[test]
811 fn store_limits_custom_table_elements_cap() {
812 let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
814 let state = WasmRuntime::create_host_state(
815 registry,
816 HashMap::new(),
817 crate::state_store::StateStore::new(),
818 50 * 1024 * 1024,
819 100,
820 50,
821 Some(200),
822 crate::capabilities::WasmCapabilities::default(),
823 );
824 let _limits: &wasmtime::StoreLimits = &state.limits;
825 }
826
827 #[test]
834 fn test_exchange_to_wasm_with_body_text_passthrough() {
835 let msg = camel_api::Message::new("hello-world");
836 let exchange = camel_api::Exchange::new(msg);
837
838 let wasm = crate::serde_bridge::exchange_to_wasm_with_body(
839 &exchange,
840 crate::bindings::camel::plugin::types::WasmBody::Text("hello-world".into()),
841 )
842 .expect("exchange_to_wasm_with_body must succeed");
843
844 assert!(
845 matches!(
846 wasm.input.body,
847 crate::bindings::camel::plugin::types::WasmBody::Text(ref s)
848 if s == "hello-world"
849 ),
850 "non-stream passthrough must preserve Text body"
851 );
852 }
853
854 #[tokio::test]
855 async fn timeout_kills_infinite_loop_guest() {
856 let wat = r#"
872 (module
873 (func (export "loop_forever")
874 loop
875 br 0
876 end
877 )
878 )
879 "#;
880
881 let mut config = wasmtime::Config::new();
882 config.epoch_interruption(true);
883 let engine = Engine::new(&config).unwrap();
884 let module = wasmtime::Module::new(&engine, wat).expect("compile wat");
885
886 let registry = Arc::new(std::sync::Mutex::new(Registry::new()));
887 let host_state = WasmRuntime::create_host_state(
888 registry,
889 HashMap::new(),
890 crate::state_store::StateStore::new(),
891 0, 10_000,
893 10_000,
894 None,
895 crate::capabilities::WasmCapabilities::default(),
896 );
897 let mut store = Store::new(&engine, host_state);
898 store.set_epoch_deadline(1);
901
902 use std::sync::atomic::{AtomicBool, Ordering};
911 let shutdown = Arc::new(AtomicBool::new(false));
912 let shutdown_clone = shutdown.clone();
913 let engine_clone = engine.clone();
914 let ticker = std::thread::spawn(move || {
915 while !shutdown_clone.load(Ordering::SeqCst) {
916 std::thread::sleep(std::time::Duration::from_millis(10));
917 engine_clone.increment_epoch();
918 }
919 });
920
921 let instance = wasmtime::Instance::new_async(&mut store, &module, &[])
922 .await
923 .expect("instantiate");
924 let func = instance
925 .get_typed_func::<(), ()>(&mut store, "loop_forever")
926 .expect("get loop_forever export");
927
928 let start = std::time::Instant::now();
929 let result = func.call_async(&mut store, ()).await;
930 let elapsed = start.elapsed();
931
932 shutdown.store(true, Ordering::SeqCst);
935 ticker.join().expect("ticker thread to exit cleanly");
936
937 assert!(
938 result.is_err(),
939 "infinite loop must be killed by epoch interruption"
940 );
941 assert!(
944 elapsed < std::time::Duration::from_secs(2),
945 "timeout must trigger quickly, took {:?}",
946 elapsed
947 );
948 }
949
950 #[tokio::test]
953 async fn drain_watchdog_trips_on_stalled_drain() {
954 let progress = Arc::new(Notify::new());
955 let drain_started = Arc::new(Notify::new());
956 let ds = drain_started.clone();
957 let drive = async move {
958 ds.notify_one();
959 std::future::pending::<()>().await;
960 Ok::<(), WasmError>(())
961 };
962 let result = WasmRuntime::drive_with_drain_watchdog(
963 drive,
964 &progress,
965 &drain_started,
966 Duration::from_millis(50),
967 None,
968 )
969 .await;
970 assert!(matches!(result, Err(WasmError::GuestPanic(_))));
971 }
972
973 #[tokio::test]
974 async fn drain_watchdog_passes_when_chunks_flow() {
975 let progress = Arc::new(Notify::new());
976 let drain_started = Arc::new(Notify::new());
977 let p = progress.clone();
978 let ds = drain_started.clone();
979 let drive = async move {
980 ds.notify_one();
981 for _ in 0..5 {
982 p.notify_one();
983 tokio::time::sleep(Duration::from_millis(10)).await;
984 }
985 Ok::<(), WasmError>(())
986 };
987 let result = WasmRuntime::drive_with_drain_watchdog(
988 drive,
989 &progress,
990 &drain_started,
991 Duration::from_millis(50),
992 None,
993 )
994 .await;
995 assert!(result.is_ok());
996 }
997
998 #[tokio::test]
999 async fn drain_watchdog_completes_without_drain_signal() {
1000 let progress = Arc::new(Notify::new());
1001 let drain_started = Arc::new(Notify::new());
1002 let drive = async { Ok::<i32, WasmError>(42) };
1003 let result = WasmRuntime::drive_with_drain_watchdog(
1004 drive,
1005 &progress,
1006 &drain_started,
1007 Duration::from_millis(50),
1008 None,
1009 )
1010 .await;
1011 assert_eq!(result.unwrap(), 42);
1012 }
1013
1014 #[tokio::test]
1015 async fn drain_watchdog_passes_through_guest_error() {
1016 let progress = Arc::new(Notify::new());
1017 let drain_started = Arc::new(Notify::new());
1018 let ds = drain_started.clone();
1019 let drive = async move {
1020 ds.notify_one();
1021 Err::<(), WasmError>(WasmError::GuestPanic("boom".into()))
1022 };
1023 let result = WasmRuntime::drive_with_drain_watchdog(
1024 drive,
1025 &progress,
1026 &drain_started,
1027 Duration::from_secs(60),
1028 None,
1029 )
1030 .await;
1031 let err = result.expect_err("guest error must propagate");
1032 assert!(err.to_string().contains("boom"));
1033 }
1034
1035 #[tokio::test]
1036 async fn cancel_completes_drive_select_without_hang() {
1037 let progress = Arc::new(Notify::new());
1038 let drain_started = Arc::new(Notify::new());
1039 let cancel = CancellationToken::new();
1040
1041 let ds = drain_started.clone();
1042 let drive = async move {
1043 ds.notify_one();
1044 tokio::time::sleep(Duration::from_millis(50)).await;
1045 Ok::<(), WasmError>(())
1046 };
1047
1048 let c = cancel.clone();
1049 tokio::spawn(async move {
1050 tokio::time::sleep(Duration::from_millis(10)).await;
1051 c.cancel();
1052 });
1053
1054 let result = tokio::time::timeout(Duration::from_secs(1), async {
1055 tokio::select! {
1056 r = WasmRuntime::drive_with_drain_watchdog(
1057 drive, &progress, &drain_started, Duration::from_secs(60), None,
1058 ) => r,
1059 _ = cancel.cancelled() => Err(WasmError::Cancelled("test cancel".into())),
1060 }
1061 })
1062 .await;
1063
1064 assert!(result.is_ok(), "select! must not hang — got timeout");
1065 }
1066
1067 #[tokio::test]
1070 async fn invoke_stall_trips_on_no_progress() {
1071 let progress = Arc::new(Notify::new());
1073 let drain_started = Arc::new(Notify::new());
1074 let drive = async move {
1075 std::future::pending::<()>().await;
1076 Ok::<(), WasmError>(())
1077 };
1078 let result = WasmRuntime::drive_with_drain_watchdog(
1079 drive,
1080 &progress,
1081 &drain_started,
1082 Duration::from_secs(60),
1083 Some(Duration::from_millis(50)),
1084 )
1085 .await;
1086 let err = result.expect_err("stalled invoke must time out");
1087 assert!(
1088 err.to_string().contains("invoke stalled"),
1089 "expected invoke stall, got: {err}"
1090 );
1091 }
1092
1093 #[tokio::test]
1094 async fn invoke_stall_progress_resets_timer() {
1095 let progress = Arc::new(Notify::new());
1097 let drain_started = Arc::new(Notify::new());
1098 let p = progress.clone();
1099 let ds = drain_started.clone();
1100 let drive = async move {
1101 for _ in 0..4 {
1103 p.notify_one();
1104 tokio::time::sleep(Duration::from_millis(25)).await;
1105 }
1106 ds.notify_one();
1107 Ok::<(), WasmError>(())
1108 };
1109 let result = WasmRuntime::drive_with_drain_watchdog(
1110 drive,
1111 &progress,
1112 &drain_started,
1113 Duration::from_secs(60),
1114 Some(Duration::from_millis(40)),
1115 )
1116 .await;
1117 assert!(
1118 result.is_ok(),
1119 "progress should have reset the stall timer, got: {result:?}"
1120 );
1121 }
1122
1123 #[tokio::test]
1124 async fn invoke_stall_completes_before_drain_started() {
1125 let progress = Arc::new(Notify::new());
1127 let drain_started = Arc::new(Notify::new());
1128 let drive = async { Ok::<i32, WasmError>(42) };
1129 let result = WasmRuntime::drive_with_drain_watchdog(
1130 drive,
1131 &progress,
1132 &drain_started,
1133 Duration::from_millis(50),
1134 Some(Duration::from_secs(60)),
1135 )
1136 .await;
1137 assert_eq!(result.unwrap(), 42);
1138 }
1139}