Skip to main content

camel_component_wasm/
runtime.rs

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
52/// Result of [`WasmRuntime::process_streaming_exchange`].
53///
54/// Carries the guest's [`WasmExchange`] and an optional drain receiver
55/// that, when `Some`, holds the guest-to-host streaming return channel
56/// that the caller must re-attach as the output [`Body::Stream`].
57pub 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        // Existence check first (preserve ModuleNotFound error variant)
79        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        // Size cap: reject oversized modules before compilation (R4-H3)
87        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            // File exists and size is OK — compilation error is genuine
92            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    /// Construct a fresh `WasmHostState` for one guest invocation.
121    ///
122    /// Always uses `wasmtime::StoreLimitsBuilder::new()` which seeds wasmtime
123    /// defaults (4 GiB memory, 10_000 instances, 10_000 tables). `max_memory_bytes`
124    /// of `0` omits the `.memory_size()` call, leaving the 4 GiB default in place.
125    /// Positive values apply the cap. `max_instances` / `max_tables` / `max_table_elements`
126    /// are always applied regardless of `max_memory_bytes` (R4-L5 fix).
127    ///
128    /// `max_instances` / `max_tables` set the per-store caps on core instances
129    /// and tables (wasmtime default: 10_000 each). `max_table_elements` caps
130    /// table elements; `None` leaves it unlimited (wasmtime default).
131    #[allow(clippy::too_many_arguments)] // 3 new R4-L5 caps + existing 5
132    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    /// Classify a wasmtime error into a structured WasmError.
165    ///
166    /// Downcasts to `wasmtime::Trap` first — if successful, routes to
167    /// Timeout/OutOfMemory/Trap variants. Otherwise falls back to GuestPanic.
168    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        // The async-with-trappable WIT shape produces a 2-layer Result:
199        //   run_concurrent Result<inner, wasmtime::Error>     (outer)
200        //   trappable     Result<Result<(), String>, wasmtime::Error>  (inner)
201        // Both layers carry wasmtime::Error — use peel_concurrent to map
202        // each to WasmError uniformly.
203        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        // 2-layer peel — outer (run_concurrent) and middle (trappable) both
250        // carry wasmtime::Error, mapped via the same classify_error closure.
251        // The innermost plugin::WasmError is left as-is and remapped below
252        // to the canonical WasmError variants.
253        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    /// Process an [`Exchange`] through the WASM guest with streaming-body
267    /// support and a no-progress watchdog.
268    ///
269    /// Unlike [`call_process`](Self::call_process), which takes a fully
270    /// materialised [`WasmExchange`], this accepts a host [`Exchange`] and
271    /// handles a `Body::Stream` input specially:
272    ///
273    /// 1. The byte stream is drained out of the `Arc<Mutex<Option<BoxStream>>>`
274    ///    **before** `run_concurrent` — that mutex is `tokio::sync::Mutex`,
275    ///    which cannot be locked from the concurrent runtime thread.
276    /// 2. Inside `run_concurrent`, the stream is re-attached as a
277    ///    guest-readable `stream<u8>` via
278    ///    [`crate::stream_bridge::assemble_stream_body`].
279    ///
280    /// A **no-progress watchdog** wraps the invocation: if no stream chunk is
281    /// shipped within `no_progress_timeout`, the call fails with a timeout.
282    /// Progress is signalled by a [`Notify`] shared with
283    /// [`crate::stream_bridge::BoxStreamProducer`], which pings it per shipped
284    /// chunk. `cancel` is forwarded to the producer (host-side cancellation
285    /// ends the stream promptly); `max_bytes` caps total bytes before an
286    /// overflow error.
287    ///
288    /// On success returns the guest's [`WasmExchange`] (same shape as
289    /// `call_process`) so callers can apply [`crate::serde_bridge::wasm_to_exchange`].
290    ///
291    /// **Spawn + rendezvous (Task 6):** the Store+Plugin+permit move into a
292    /// spawned drain task. A oneshot hands the exchange out fast (right after
293    /// the guest returns, before the drain completes). The drain task races
294    /// the drain against `receiver_gone.notified()` (cancel-on-drop, spec §5)
295    /// and wraps the long-lived `run_concurrent` with `drive_with_drain_watchdog`.
296    #[allow(clippy::too_many_arguments)] // mirrors call_process + 3 streaming knobs
297    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        // Take the body out of the exchange so any stream can be extracted
329        // before run_concurrent. Non-stream bodies are restored for the
330        // closure to route through exchange_to_wasm (→ body_to_wasm), exactly
331        // like call_process.
332        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        // Rendezvous: fires (exchange, drain_rx) out of the spawned task as soon
347        // as the guest returns; the task keeps draining afterward (F1).
348        // Carries `Result` so errors can propagate through the handoff too (not just
349        // success path).
350        // NEW-B: classify_error is `&self`; the spawned task can't borrow &self.
351        // Capture BOTH config + module_path (runtime.rs:143 → config.rs:161):
352        let classify_config = self.config.clone(); // WasmConfig: Clone
353        let classify_module_path = self.module_path.clone(); // PathBuf → Clone
354
355        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, // drain_completion_notify: production plugin path doesn't install it
365            // Plugin make_drive closure — mirrors bean.rs (keep in sync; the shared
366            // spawn_return_drain scaffold is identical, only the binding differs).
367            move |handoff_shared, dtx, drx, drain_started, coord| async move {
368                // NEW-7: convert the camel_api Exchange → binding WasmExchange (mirrors
369                // runtime.rs:330-336). For non-stream inputs, we can convert now.
370                // For stream inputs, the conversion happens inside run_concurrent
371                // (needs the &Accessor to assemble the stream body).
372                let wx = crate::serde_bridge::exchange_to_wasm(&exchange)
373                    .expect("exchange_to_wasm for stream-return path"); // allow-unwrap
374
375                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))); // drx moves out (F1)
416                                    }
417                                    // Cancel-on-drop select! (F2, spec §5): race drain against
418                                    // receiver_gone (ChannelConsumer fires it on poll_reserve Err).
419                                    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); // unused channel
433                                }
434                            }
435                            Ok(())
436                        })
437                        .await;
438                    // NEW-A: peel_concurrent takes 3 args (error.rs:221) — nested result +
439                    // map_outer (wasmtime::Error → WasmError via hoisted classify_error) +
440                    // map_inner (binding WasmError → crate WasmError). Mirror bean.rs:71-73.
441                    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                // If the inner async returned an error AND we haven't sent through handoff yet,
452                // send the error now.
453                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    /// Phase-aware watchdog:
474    /// - Phase 1 (invoke): waits for `drain_started` or completion. If
475    ///   `invoke_stall_timeout` is `Some`, arms a progress watchdog bound by
476    ///   that duration (used when the guest has streaming input — catches
477    ///   upstream stalls that epoch can't reach). `None` = unguarded.
478    /// - Phase 2 (drain): always arms the progress watchdog with
479    ///   `drain_timeout`.
480    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        // ── Phase 1: invoke ──
492        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        // ── Phase 2: drain ──
512        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        // Defensive: passing 0 must not produce a StoreLimits that blocks all
558        // memory growth — it should fall back to wasmtime's default.
559        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; // smoke test: constructor tolerates 0
571    }
572
573    #[tokio::test]
574    async fn create_host_state_zero_memory_still_applies_instance_caps() {
575        // Regression test for the R4-L5 fix: when max_memory_bytes=0, the old
576        // code path called StoreLimits::default() which dropped all non-memory
577        // caps (instances/tables/table_elements). The new builder path applies
578        // them unconditionally.
579        //
580        // Behavioral proof: max_instances=1 with max_memory_bytes=0. Creating a
581        // second core instance must fail.
582        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, // max_memory_bytes = 0 (no memory cap)
598            1, // max_instances = 1 (tiny cap — must be applied!)
599            1, // max_tables = 1
600            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        // First instance fits within cap=1
607        let _inst = wasmtime::Instance::new_async(&mut store, &module, &[])
608            .await
609            .expect("first instance must succeed");
610
611        // Second instance would exceed cap=1 — must be rejected
612        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    // Per-plugin-type coverage: this test runs at the shared `create_host_state`
623    // layer, so its enforcement guarantee applies to every plugin type (Processor,
624    // Bean, AuthorizationPolicy, SecurityPolicy) — they all call this function.
625    #[tokio::test]
626    async fn memory_growth_rejected_past_configured_cap() {
627        // Behavioral test for acceptance criterion #4: max_memory_bytes is
628        // *actually enforced*. Builds a tiny core-wasm module that exports a
629        // `grow` function calling `memory.grow(64)` (requesting ~4 MiB) against
630        // a host state with a 64 KiB cap. The grow call must return -1.
631        //
632        // We use a core (non-component) module here because the limiter is
633        // applied at the wasmtime::Store level — the same store that components
634        // use — so core wasm exercises the same enforcement path without the
635        // boilerplate of synthesizing a full component.
636        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, // 64 KiB cap
655            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        // memory.grow(64) requests 64 pages = 4 MiB, far above the 64 KiB cap.
671        // The limiter must refuse it; memory.grow returns -1 on rejection.
672        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        // Companion to the above: a growth request that stays under the cap
682        // must succeed. Guards against the limiter being accidentally
683        // over-restrictive.
684        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        // Cap = 1 page initial + 1 page growable = 2 pages = 128 KiB.
699        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        // memory.grow(1) requests 1 page = 64 KiB. With a 128 KiB cap and
720        // 1 page initial, the growable budget is 64 KiB (one page). Must succeed
721        // and return the previous page count (1, since initial is 1 page).
722        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        // NOTE: wasmtime v31 does not expose `get_epoch_deadline()` on Store,
762        // so we cannot assert the value was set. This test verifies the API
763        // compiles and does not panic at runtime. The actual deadline enforcement
764        // is validated indirectly by the epoch_interruption integration tests.
765    }
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, // 1 KiB cap; threaded through create_host_state
779            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        // Verifies store.limiter accepts WasmHostState::limits after the new
787        // create_host_state wires the memory cap through StoreLimitsBuilder.
788    }
789
790    #[test]
791    fn store_limits_default_no_table_elements_cap() {
792        // When max_table_elements=None, the builder does NOT call
793        // .table_elements() — wasmtime unlimited default preserved.
794        // instances/tables at 10_000 (wasmtime defaults).
795        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, // 50 MiB — exercises the builder path
801            10_000,
802            10_000,
803            None,
804            crate::capabilities::WasmCapabilities::default(),
805        );
806        // state.limits exists and did not panic — builder accepted defaults.
807        let _limits: &wasmtime::StoreLimits = &state.limits;
808    }
809
810    #[test]
811    fn store_limits_custom_table_elements_cap() {
812        // When max_table_elements=Some(n), the builder calls .table_elements(n).
813        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    // ── Non-stream passthrough (M2) ────────────────────────────────────
828    //
829    // Drives exchange_to_wasm_with_body on the path that process_streaming_exchange
830    // uses for non-stream bodies — proves the Body::Text path is equivalent
831    // to what call_process would produce.
832
833    #[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        // Behavioral test for the timeout half of the safety net:
857        // a guest that loops forever must be killed by epoch interruption
858        // within a bounded time of the configured deadline.
859        //
860        // This test is the runtime-level proof of acceptance criterion #5
861        // ("timeout_secs honoured end-to-end"). All plugin types (Processor,
862        // Bean, AuthorizationPolicy, SecurityPolicy) share the same
863        // `create_host_state` + `set_epoch_deadline` mechanism, so proving
864        // it once at the runtime level covers every plugin type.
865        //
866        // Per-plugin-type coverage: this test runs at the shared
867        // `create_host_state` + `set_epoch_deadline` layer, so its enforcement
868        // guarantee applies to every plugin type (Processor, Bean,
869        // AuthorizationPolicy, SecurityPolicy) — they all call `create_host_state`
870        // and `store.set_epoch_deadline(...)`.
871        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, // no memory cap — this test is about timeout, not memory
892            10_000,
893            10_000,
894            None,
895            crate::capabilities::WasmCapabilities::default(),
896        );
897        let mut store = Store::new(&engine, host_state);
898        // Very short deadline: 1 epoch tick. With a 10ms tick interval, this
899        // means the call must be interrupted within ~20ms (one tick + deadline).
900        store.set_epoch_deadline(1);
901
902        // Spawn the epoch ticker on a dedicated OS thread. This mirrors the
903        // production EpochTicker::start wiring (also a dedicated OS thread,
904        // see epoch.rs) — a tokio::spawn ticker would be queued behind
905        // call_async on a single-worker runtime and never get polled, so the
906        // epoch deadline would never fire. A std::thread is scheduled by the
907        // kernel and increments the epoch regardless of tokio's cooperation.
908        // The shutdown flag lets us stop the thread as soon as the assertion
909        // succeeds, instead of letting it run for the full ~2s budget.
910        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        // Stop the ticker thread before asserting — keeps the test tidy and
933        // prevents the thread from outliving the test by ~2 seconds.
934        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        // Loose upper bound: must interrupt within 2 seconds even on slow CI.
942        // A typical run is ~20ms.
943        assert!(
944            elapsed < std::time::Duration::from_secs(2),
945            "timeout must trigger quickly, took {:?}",
946            elapsed
947        );
948    }
949
950    // ── drive_with_drain_watchdog ─────────────────────────────────────
951
952    #[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    // ── Phase 1 invoke-stall watchdog (streaming-input arming) ────────
1068
1069    #[tokio::test]
1070    async fn invoke_stall_trips_on_no_progress() {
1071        // Some(timeout): no progress, no drain_started, no completion → trip.
1072        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        // Some(timeout): periodic progress pings prevent the trip.
1096        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            // Pinger: 4 pings 25ms apart, then drain_started at 120ms.
1102            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        // Some(timeout): run_fut resolves immediately → return value.
1126        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}