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}