Skip to main content

camel_component_wasm/
source_consumer.rs

1//! WasmSourceConsumer — Consumer trait implementation for the `source` world.
2//!
3//! The guest IS the source: it owns an async `run()` loop that awaits
4//! host-provided `accept-http` / `submit-exchange`. The host bridges the
5//! async guest to the pipeline via bounded tokio channels, and uses the
6//! cancel token (raced in each import) + epoch deadline for cancellation.
7
8use std::path::PathBuf;
9use std::sync::Arc;
10use std::time::Duration;
11
12use camel_api::CamelError;
13use camel_component_api::{ConcurrencyModel, Consumer, ConsumerContext};
14use tokio::task::JoinHandle;
15use tokio_util::sync::CancellationToken;
16use tracing::{debug, warn};
17use wasmtime::component::{Component, Linker};
18use wasmtime::{AsContextMut, Config, Engine, Store};
19
20use crate::config::WasmConfig;
21use crate::source_bindings::Source;
22use crate::source_bindings::camel::plugin::source_host::CapabilityRequest;
23use crate::source_host::{
24    DEFAULT_MAX_REQUEST_BODY_BYTES, HttpListenerHandle, SourceChannels, SourceHostState,
25    add_to_linker, run_http_listener, run_pipeline_bridge,
26};
27
28/// Epoch deadline (in ticks) granted to the guest's `configure()` call.
29///
30/// No epoch ticker runs during `configure()`, so this budget is never
31/// decremented; it exists solely to move the store off its default
32/// already-expired deadline (0) that would otherwise trap the guest at
33/// the first epoch check when `epoch_interruption(true)` is enabled.
34const CONFIGURE_EPOCH_DEADLINE: u64 = u64::MAX;
35
36/// Consumer backed by a WASM `source` world component.
37pub struct WasmSourceConsumer {
38    module_path: PathBuf,
39    guest_config: Vec<(String, String)>,
40    config: WasmConfig,
41    #[allow(dead_code)]
42    registry: Arc<std::sync::Mutex<camel_core::Registry>>,
43    cancel_token: CancellationToken,
44    engine: Option<Arc<Engine>>,
45    run_task: Option<JoinHandle<Result<(), CamelError>>>,
46    listener_task: Option<JoinHandle<()>>,
47    bridge_task: Option<JoinHandle<()>>,
48}
49
50impl WasmSourceConsumer {
51    pub fn new(
52        module_path: PathBuf,
53        config: WasmConfig,
54        guest_config: Vec<(String, String)>,
55        registry: Arc<std::sync::Mutex<camel_core::Registry>>,
56    ) -> Self {
57        Self {
58            module_path,
59            guest_config,
60            config,
61            registry,
62            cancel_token: CancellationToken::new(),
63            engine: None,
64            run_task: None,
65            listener_task: None,
66            bridge_task: None,
67        }
68    }
69}
70
71#[async_trait::async_trait]
72impl Consumer for WasmSourceConsumer {
73    async fn start(&mut self, ctx: ConsumerContext) -> Result<(), CamelError> {
74        // 1. Engine config: component model + async + epoch interruption.
75        //    concurrency_support is REQUIRED for Store::run_concurrent /
76        //    call_run_async (async source world). Mirrors the plugin engine
77        //    (runtime.rs).
78        let mut wasm_config = Config::new();
79        wasm_config.wasm_component_model(true);
80        wasm_config.epoch_interruption(true);
81        wasm_config.concurrency_support(true);
82        let engine = Arc::new(
83            Engine::new(&wasm_config)
84                .map_err(|e| CamelError::ProcessorError(format!("wasmtime engine: {e}")))?,
85        );
86
87        // 2. Size cap: reject oversized modules before compilation (R4-H3)
88        crate::config::validate_wasm_size(&self.module_path, self.config.max_wasm_size_bytes)
89            .map_err(CamelError::ProcessorError)?;
90
91        // 3. Load component
92        let component = Component::from_file(&engine, &self.module_path)
93            .map_err(|e| CamelError::ProcessorError(format!("component load: {e}")))?;
94
95        // 3. Create bounded channels
96        let channels = SourceChannels::new();
97
98        // 4. Create WASI context (guest targets wasm32-wasip2)
99        let wasi = wasmtime_wasi::WasiCtxBuilder::new().build();
100
101        // 5. Create store with host state
102        let cancel = self.cancel_token.clone();
103        let mut store = Store::new(
104            &engine,
105            SourceHostState {
106                table: wasmtime::component::ResourceTable::new(),
107                wasi,
108                request_rx: channels.request_rx,
109                exchange_tx: channels.exchange_tx,
110                cancel_token: cancel.clone(),
111                max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES,
112            },
113        );
114
115        // 6. Create linker + register host functions
116        let mut linker: Linker<SourceHostState> = Linker::new(&engine);
117        add_to_linker(&mut linker)
118            .map_err(|e| CamelError::ProcessorError(format!("linker: {e}")))?;
119
120        // 7. Set a large epoch deadline before any guest code runs. With
121        // epoch_interruption(true) the store's default deadline is 0 (already
122        // expired); instantiate_async and configure both run guest WASM, so
123        // without this they trap immediately with "wasm trap: interrupt". No
124        // epoch ticker runs during setup, so a u64::MAX budget disables
125        // interruption for both calls. The run task later resets this to 1
126        // (a pure stop tripwire). Mirrors the plugin path (runtime.rs:175).
127        store.set_epoch_deadline(CONFIGURE_EPOCH_DEADLINE);
128
129        // 8. Instantiate component (async bindings — see source_bindings.rs).
130        let source = Source::instantiate_async(&mut store, &component, &linker)
131            .await
132            .map_err(|e| CamelError::ProcessorError(format!("instantiate: {e}")))?;
133
134        // 9. Call guest configure() → SourcePlan
135        //
136        // configure() is a SYNC export (only run/accept-http/submit-exchange
137        // are async), but concurrency_support(true) forbids the sync
138        // `TypedFunc::call` path — so dispatch it via `call_async` on the
139        // underlying typed func. No async imports are needed during configure,
140        // so this stays out of run_concurrent. The large deadline set above
141        // covers it.
142        let (plan_result,) = source
143            .func_configure()
144            .call_async(&mut store, (self.guest_config.as_slice(),))
145            .await
146            .map_err(|e| CamelError::ProcessorError(format!("configure: {e}")))?;
147        let plan = plan_result
148            .map_err(|e| CamelError::ProcessorError(format!("configure guest error: {e:?}")))?;
149
150        // 10. Validate source-plan
151        if plan.capabilities.len() != 1 {
152            return Err(CamelError::EndpointCreationFailed(
153                "source-plan must have exactly one capability".into(),
154            ));
155        }
156        let CapabilityRequest::HttpListener(listener_spec) = &plan.capabilities[0];
157
158        // 10b. Reject unsupported concurrency. The host drains the guest
159        // strictly sequentially through capacity-1 channels, so silently
160        // degrading `concurrent(N)` to sequential would violate the guest's
161        // declared contract. Reject explicitly until concurrent mode exists.
162        use crate::source_bindings::camel::plugin::source_host::ConcurrencyModel as PlanConcurrency;
163        match plan.concurrency {
164            PlanConcurrency::Sequential => {}
165            PlanConcurrency::Concurrent(max) => {
166                return Err(CamelError::EndpointCreationFailed(format!(
167                    "WASM source does not support concurrent({max}); only sequential is implemented"
168                )));
169            }
170        }
171
172        // 11. Create http-listener resource handle in the table
173        let listener = store
174            .data_mut()
175            .table
176            .push(HttpListenerHandle)
177            .map_err(|e| CamelError::ProcessorError(format!("resource table: {e}")))?;
178
179        // 12. Parse bind address from listener spec
180        let bind_addr: std::net::SocketAddr = listener_spec.bind.parse().map_err(|e| {
181            CamelError::EndpointCreationFailed(format!(
182                "invalid bind address '{}': {}",
183                listener_spec.bind, e
184            ))
185        })?;
186        let path_filter = listener_spec.path.clone();
187
188        // 13. Bind the TCP listener synchronously BEFORE spawning, so a bind
189        // failure (port in use, permission denied) surfaces as a start()
190        // error rather than a background warning. Without this the guest
191        // exits cleanly and the route looks healthy with nothing accepting.
192        let tcp_listener = tokio::net::TcpListener::bind(bind_addr)
193            .await
194            .map_err(|e| {
195                CamelError::Io(format!(
196                    "failed to bind source HTTP listener {bind_addr}: {e}"
197                ))
198            })?;
199        tracing::info!(%bind_addr, "source HTTP listener bound");
200
201        // 14. Spawn HTTP listener task (serve on the already-bound listener)
202        let listener_cancel = cancel.clone();
203        let request_tx = channels.request_tx;
204        let lt = tokio::spawn(async move {
205            if let Err(e) =
206                run_http_listener(tcp_listener, path_filter, request_tx, listener_cancel).await
207            {
208                warn!("WASM source HTTP listener exited: {e:?}");
209            }
210        });
211
212        // 14. Spawn pipeline bridge task
213        let bridge_ctx = ctx;
214        let exchange_rx = channels.exchange_rx;
215        let bt = tokio::spawn(async move {
216            if let Err(e) = run_pipeline_bridge(exchange_rx, bridge_ctx).await {
217                warn!("WASM source pipeline bridge exited: {e:?}");
218            }
219        });
220
221        // 15. Spawn the async guest run() loop on a tokio task — THE CRITICAL STEP.
222        //
223        // The guest's `run` export is async; its host imports (accept-http,
224        // submit-exchange) are async too. The whole thing is driven under
225        // `Store::run_concurrent`, which is what makes the accessor-based
226        // async imports dispatchable (concurrency_support(true) is required).
227        //
228        // Epoch: deadline(1) is a pure stop tripwire. No epoch ticker runs
229        // during normal operation (each source consumer owns a private engine),
230        // so the deadline never advances until stop()'s single
231        // engine.increment_epoch(). That one increment traps a guest stuck in
232        // a CPU-bound loop; a guest parked in an import is unblocked first by
233        // the import's cancel_token select!.
234        //
235        // No watchdog: the source `run()` is an unbounded loop that
236        // legitimately parks in `accept-http` for arbitrary durations between
237        // requests. A fixed no-progress timeout would kill idle webhook
238        // sources. The existing safeguards cover all failure modes:
239        //   - Epoch interruption (deadline(1) + increment_epoch from stop())
240        //     catches CPU-bound guest loops.
241        //   - Cancel-token select! inside each import unblocks park-on-stop.
242        //   - Route-level supervision catches "guest hangs forever" at the
243        //     route manager level.
244        let run_handle = tokio::spawn(async move {
245            store.set_epoch_deadline(1);
246
247            // 3-layer result: run_concurrent Result< trappable Result<
248            // WIT Result > >. peel_concurrent flattens the outer two
249            // wasmtime::Error layers.
250            let result = crate::error::peel_concurrent(
251                store
252                    .as_context_mut()
253                    .run_concurrent(async |accessor| source.call_run(accessor, listener).await)
254                    .await,
255                |e| CamelError::ProcessorError(format!("guest trap: {e}")),
256                |e| CamelError::ProcessorError(format!("guest trap: {e}")),
257            );
258
259            match result {
260                Ok(Ok(())) => {
261                    debug!("WASM source guest run() exited normally");
262                    Ok(())
263                }
264                Ok(Err(e)) => {
265                    warn!("WASM source guest run() returned error: {e:?}");
266                    Err(CamelError::ProcessorError(format!("guest run: {e:?}")))
267                }
268                Err(e) => {
269                    warn!("WASM source guest trapped: {e}");
270                    Err(e)
271                }
272            }
273        });
274
275        // 16. Store engine + JoinHandles
276        self.engine = Some(engine);
277        self.run_task = Some(run_handle);
278        self.listener_task = Some(lt);
279        self.bridge_task = Some(bt);
280
281        Ok(())
282    }
283
284    async fn stop(&mut self) -> Result<(), CamelError> {
285        // Idempotent — safe on any exit path
286        self.cancel_token.cancel();
287
288        // Epoch tick — interrupts CPU-bound guest loops
289        if let Some(ref engine) = self.engine {
290            engine.increment_epoch();
291        }
292
293        // The Runtime owns and monitors run_task via background_task_handle().
294        // If the Runtime has already taken the handle, run_task is None here
295        // and the Runtime is responsible for joining it. If we still own it
296        // (stop() called without background_task_handle, or a test path),
297        // join with a grace timeout and abort on timeout — dropping a
298        // JoinHandle only detaches the task (keeps it running forever).
299        let grace = std::time::Duration::from_secs(5);
300        if let Some(task) = self.run_task.take() {
301            join_or_abort(task, "source run", grace).await;
302        }
303
304        // Join listener + bridge tasks. Graceful shutdown via the cancel token
305        // is raced against a timeout; on timeout we abort() the task.
306        if let Some(task) = self.listener_task.take() {
307            join_or_abort(task, "listener", grace).await;
308        }
309        if let Some(task) = self.bridge_task.take() {
310            join_or_abort(task, "bridge", grace).await;
311        }
312
313        Ok(())
314    }
315
316    fn concurrency_model(&self) -> ConcurrencyModel {
317        ConcurrencyModel::Sequential
318    }
319
320    fn background_task_handle(&mut self) -> Option<JoinHandle<Result<(), CamelError>>> {
321        self.run_task.take()
322    }
323}
324
325/// Race joining `task` against a `grace` timeout; on timeout, `abort()` it
326/// and await the abort so the task is gone before returning.
327///
328/// Dropping a [`JoinHandle`] only *detaches* the task — it keeps running in
329/// the background. Only [`JoinHandle::abort`] actually cancels it, so this is
330/// what makes `stop()` deterministic: once it returns, the task is not still
331/// running.
332async fn join_or_abort<T: Send + 'static>(task: JoinHandle<T>, label: &str, grace: Duration) {
333    tokio::pin!(task);
334    tokio::select! {
335        result = &mut task => {
336            if let Err(e) = result {
337                warn!("{label} task panicked on shutdown: {e}");
338            }
339        }
340        _ = tokio::time::sleep(grace) => {
341            warn!("{label} task did not exit within {grace:?}, aborting");
342            task.abort();
343            // Await the cancellation so the task is truly gone.
344            if let Err(e) = task.await {
345                debug!("{label} task aborted: {e}");
346            }
347        }
348    }
349}
350
351/// Extract guest config (bind, path, etc.) from URI query params.
352/// WasmConfig::from_uri drops unknown params, so we parse them separately.
353pub fn parse_guest_config(uri: &str) -> Vec<(String, String)> {
354    let query = match uri.find('?') {
355        Some(i) => &uri[i + 1..],
356        None => return Vec::new(),
357    };
358
359    query
360        .split('&')
361        .filter_map(|pair| {
362            let (k, v) = pair.split_once('=')?;
363            // Capture all params the guest might need
364            if matches!(k, "bind" | "path" | "method") {
365                Some((k.to_string(), v.to_string()))
366            } else {
367                None
368            }
369        })
370        .collect()
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use std::sync::Mutex;
377
378    #[tokio::test]
379    async fn stop_does_not_wait_for_runtime_owned_run_task() {
380        let config = WasmConfig {
381            timeout_secs: 1,
382            ..WasmConfig::default()
383        };
384        let mut consumer = WasmSourceConsumer::new(
385            PathBuf::from("unused.wasm"),
386            config,
387            Vec::new(),
388            Arc::new(Mutex::new(camel_core::Registry::new())),
389        );
390        // Mirrors the real run task shape (tokio::spawn of an async future).
391        consumer.run_task = Some(tokio::spawn(async {
392            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
393            Ok(())
394        }));
395
396        // Simulate the Runtime taking ownership of the run task handle.
397        // Once taken, stop() no longer sees run_task and must not wait for it.
398        let _runtime_owned = consumer.background_task_handle();
399
400        tokio::time::timeout(std::time::Duration::from_millis(100), consumer.stop())
401            .await
402            .expect("stop() must not wait for a runtime-owned run_task")
403            .expect("stop() should succeed");
404    }
405
406    /// When stop() still owns the run task (Runtime hasn't taken it), it must
407    /// abort it after the grace timeout rather than detaching (leaking) it.
408    #[tokio::test]
409    async fn stop_aborts_owned_run_task_after_grace() {
410        let config = WasmConfig {
411            timeout_secs: 1,
412            ..WasmConfig::default()
413        };
414        let mut consumer = WasmSourceConsumer::new(
415            PathBuf::from("unused.wasm"),
416            config,
417            Vec::new(),
418            Arc::new(Mutex::new(camel_core::Registry::new())),
419        );
420        // A run task that never exits on its own — only abort will stop it.
421        consumer.run_task = Some(tokio::spawn(async {
422            tokio::time::sleep(std::time::Duration::from_secs(60)).await;
423            Ok(())
424        }));
425
426        // stop() should abort the stuck task within the grace window (5s) +
427        // margin. Asserting 15s ceiling; the abort should fire at ~5s.
428        let result =
429            tokio::time::timeout(std::time::Duration::from_secs(15), consumer.stop()).await;
430        assert!(
431            result.is_ok(),
432            "stop() must abort a stuck run task within the grace window, not hang"
433        );
434        result.unwrap().expect("stop() should succeed");
435    }
436}