camel_component_wasm/source_host.rs
1//! Source host state, linker functions, and host imports.
2//!
3//! This module provides:
4//! - [`SourceHostState`]: host state for the source world (separate from `WasmHostState`)
5//! - [`SourceChannels`]: bounded tokio channels bridging async guest imports to host tasks
6//! - `HostWithStore` impl for async `accept-http`, `submit-exchange`; sync `Host` impl for
7//! `is-cancelled`
8//! - [`run_http_listener`]: async axum task feeding HTTP requests into the channel
9//! - [`run_pipeline_bridge`]: async task forwarding exchanges to the pipeline
10
11use std::sync::Arc;
12
13use bytes::Bytes;
14use tokio::sync::{Notify, mpsc, oneshot};
15use tokio_util::sync::CancellationToken;
16use wasmtime::component::{Accessor, AccessorTask, HasSelf, Linker, Resource, ResourceTable};
17
18use camel_api::{Body, CamelError, Exchange, ExchangePattern, Message, StreamBody, Value};
19use camel_component_api::consumer::ConsumerContext;
20
21use crate::return_stream::{
22 DEFAULT_DRAIN_CHANNEL_BOUND, DrainCoord, DrainEvent, StreamReturnable, TerminalSlot,
23 drain_guest_stream, receiver_to_body_stream,
24};
25use crate::source_bindings::camel::plugin::source_host::{HttpRequest, SubmitOutcome};
26use crate::source_bindings::camel::plugin::types::{
27 WasmBody, WasmExchange, WasmMessage, WasmPattern,
28};
29
30/// Concrete type for the http-listener resource in the ResourceTable.
31/// Stateless — the handle is a marker that the guest holds while running.
32/// Mapped via `with:` in source_bindings.rs to replace the empty enum
33/// generated by bindgen for the `resource http-listener` declaration.
34pub struct HttpListenerHandle;
35
36/// Materialized HTTP request metadata (everything except the body).
37///
38/// Sent through the request channel alongside a per-request body receiver.
39/// The body itself is streamed incrementally through the receiver — never
40/// materialized as a single `Vec<u8>` — so large payloads are not capped.
41pub struct HttpMeta {
42 pub method: String,
43 pub path: String,
44 pub headers: Vec<(String, String)>,
45}
46
47/// Per-request body chunk receiver. The host's axum handler streams body
48/// frames into the matching `mpsc::Sender`; the guest reads via the
49/// `stream-body-handle` minted by `accept_http`.
50type BodyChunkRx = mpsc::Receiver<Result<Bytes, CamelError>>;
51
52/// Request channel payload: metadata + body streaming receiver.
53type RequestChannelItem = (HttpMeta, BodyChunkRx);
54
55// ─── Channel capacity constants ───────────────────────────────────────────
56
57/// Capacity for the HTTP request channel (host listener → guest).
58///
59/// 1 propagates backpressure all the way to the HTTP client: the axum handler
60/// `.send().await`s onto this channel, so once the guest is busy draining a
61/// prior exchange into the (also capacity-1) pipeline, further inbound
62/// requests park on the send until the guest calls `accept-http` again. A
63/// larger buffer would let a burst of clients receive 202 responses for work
64/// the pipeline has not yet accepted, silently decoupling client-visible
65/// success from actual delivery — undesirable for a source that must not
66/// acknowledge messages it may drop on shutdown.
67pub const REQUEST_CHANNEL_CAPACITY: usize = 1;
68
69/// Capacity for the exchange channel (guest → pipeline bridge).
70/// 1 enforces strict backpressure — guest blocks until pipeline accepts.
71pub const EXCHANGE_CHANNEL_CAPACITY: usize = 1;
72
73// ─── SourceChannels ───────────────────────────────────────────────────────
74
75/// Paired channel endpoints for bridging the guest to async host tasks.
76///
77/// Created once per source consumer instance. The `*_tx` halves go to the
78/// HTTP listener and pipeline bridge tasks; `request_rx` lives in
79/// [`SourceHostState`] inside the wasmtime `Store`, wrapped in an
80/// `Arc<tokio::sync::Mutex<…>>` so the async host import (which receives only
81/// `&Accessor`, not `&mut self`) can `lock().await` it to call `recv().await`.
82///
83/// The request channel carries `(HttpMeta, body_rx)` — metadata plus a
84/// per-request bounded channel streaming body chunks. The body is never
85/// materialized as a single `Vec<u8>`, removing the old 10 MiB cap.
86pub struct SourceChannels {
87 pub request_tx: mpsc::Sender<RequestChannelItem>,
88 pub request_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<RequestChannelItem>>>,
89 pub exchange_tx: mpsc::Sender<(Exchange, oneshot::Sender<SubmitOutcome>)>,
90 pub exchange_rx: mpsc::Receiver<(Exchange, oneshot::Sender<SubmitOutcome>)>,
91}
92
93impl SourceChannels {
94 /// Create a new set of channels with the documented capacities.
95 pub fn new() -> Self {
96 let (request_tx, request_rx) = mpsc::channel(REQUEST_CHANNEL_CAPACITY);
97 let (exchange_tx, exchange_rx) = mpsc::channel(EXCHANGE_CHANNEL_CAPACITY);
98 Self {
99 request_tx,
100 request_rx: Arc::new(tokio::sync::Mutex::new(request_rx)),
101 exchange_tx,
102 exchange_rx,
103 }
104 }
105}
106
107impl Default for SourceChannels {
108 fn default() -> Self {
109 Self::new()
110 }
111}
112
113// ─── SourceHostState ──────────────────────────────────────────────────────
114
115/// Default maximum request body size in bytes (10 MiB).
116///
117/// Matches [`camel_api::body::DEFAULT_MATERIALIZE_LIMIT`] — the same cap the
118/// old `to_bytes` path enforced. Restores the DoS backstop that was
119/// inadvertently removed when the body switched from materialized to streamed.
120pub const DEFAULT_MAX_REQUEST_BODY_BYTES: u64 = 10 * 1024 * 1024;
121
122/// Host state for a source consumer. Lives inside the `wasmtime::Store`.
123///
124/// Separate from [`crate::runtime::WasmHostState`] because the source world
125/// has a fundamentally different lifecycle: the guest IS the source, owns a
126/// run loop, and communicates via channels rather than direct call-process.
127///
128/// `request_rx` is wrapped in `Arc<tokio::sync::Mutex<…>>` because the async
129/// host imports receive `&Accessor` (shared access), not `&mut self`; the
130/// Mutex provides the interior mutability needed to `recv().await` the
131/// receiver across import calls.
132pub struct SourceHostState {
133 pub table: ResourceTable,
134 pub wasi: wasmtime_wasi::WasiCtx,
135 pub request_rx: Arc<tokio::sync::Mutex<mpsc::Receiver<RequestChannelItem>>>,
136 pub exchange_tx: mpsc::Sender<(Exchange, oneshot::Sender<SubmitOutcome>)>,
137 pub cancel_token: CancellationToken,
138 /// Maximum bytes the guest may read from a single request body before
139 /// the stream producer terminates with an overflow error. Restores the
140 /// DoS backstop that the old `to_bytes(MAX_BODY_BYTES)` path provided.
141 pub max_request_body_bytes: u64,
142}
143
144impl wasmtime_wasi::WasiView for SourceHostState {
145 fn ctx(&mut self) -> wasmtime_wasi::WasiCtxView<'_> {
146 wasmtime_wasi::WasiCtxView {
147 ctx: &mut self.wasi,
148 table: &mut self.table,
149 }
150 }
151}
152
153// ─── Host trait implementation ────────────────────────────────────────────
154
155type SourceWasmError = crate::source_bindings::camel::plugin::types::WasmError;
156
157// The http-listener resource has no methods in WIT, but wasmtime bindgen
158// generates a HostHttpListener trait that must be implemented.
159impl crate::source_bindings::camel::plugin::source_host::HostHttpListener for SourceHostState {
160 fn drop(
161 &mut self,
162 _resource: wasmtime::component::Resource<HttpListenerHandle>,
163 ) -> wasmtime::Result<()> {
164 // HttpListenerHandle is stateless; nothing to clean up.
165 Ok(())
166 }
167}
168
169impl crate::source_bindings::camel::plugin::source_host::Host for SourceHostState {
170 fn is_cancelled(&mut self) -> bool {
171 // Stays sync (a `with`-style peek that must not yield). Returns
172 // immediately so tight guest loops can poll cancellation cheaply.
173 self.cancel_token.is_cancelled()
174 }
175}
176
177/// Async host imports. The guest's `run` (async) awaits these; each receives
178/// `&Accessor<SourceHostState, HasSelf<SourceHostState>>` (shared access into
179/// the store, NOT `&mut self`). We snapshot owned values (clones) out of a
180/// `with` call, then `.await` OUTSIDE it — `Accessor::with` is sync and must
181/// not be held across an await. Cancellation races every await against the
182/// shared `CancellationToken` so `stop()` unblocks a parked import promptly.
183impl crate::source_bindings::camel::plugin::source_host::HostWithStore<SourceHostState>
184 for wasmtime::component::HasSelf<SourceHostState>
185{
186 async fn accept_http(
187 accessor: &Accessor<SourceHostState, wasmtime::component::HasSelf<SourceHostState>>,
188 _listener: Resource<HttpListenerHandle>,
189 ) -> Result<Option<HttpRequest>, SourceWasmError> {
190 // Snapshot clones via `with`, then await outside. request_rx is an
191 // Arc<Mutex<Receiver>>; cloning the Arc is cheap and lets us lock it
192 // across the await without borrowing the accessor.
193 // Single `with` snapshot — one TLS lookup for all three fields.
194 let (request_rx, cancel_token, max_body) = accessor.with(|mut view| {
195 let state = view.get();
196 (
197 state.request_rx.clone(),
198 state.cancel_token.clone(),
199 state.max_request_body_bytes,
200 )
201 });
202
203 // Uncontended lock: the guest runs a single sequential run loop, so
204 // only one accept_http is in flight at a time.
205 let mut guard = request_rx.lock().await;
206 let meta_and_body = tokio::select! {
207 r = guard.recv() => r,
208 _ = cancel_token.cancelled() => {
209 return Ok(None);
210 }
211 };
212 // Drop the lock before minting the StreamReader (needs accessor.with).
213 drop(guard);
214
215 // None => channel closed (listener exited); treat as cancellation.
216 let Some((meta, mut body_rx)) = meta_and_body else {
217 return Ok(None);
218 };
219
220 // Build a BoxStream from the per-request body channel via poll_fn.
221 // NOT receiver_to_body_stream — that is DrainEvent-specific (the
222 // submit-exchange direction). Here the channel carries
223 // Result<Bytes, CamelError> directly.
224 let box_stream: futures::stream::BoxStream<'static, Result<Bytes, CamelError>> =
225 Box::pin(futures::stream::poll_fn(move |cx| body_rx.poll_recv(cx)));
226
227 // Mint the StreamReader + terminal FutureReader inside accessor.with.
228 // assemble_stream_body_source takes &Accessor<S, U> and internally
229 // calls accessor.with for each reader creation.
230 let cancel = cancel_token.clone();
231 let handle = crate::stream_bridge::assemble_stream_body_source(
232 accessor, box_stream, cancel, max_body,
233 );
234 let request = match handle {
235 Ok(body) => Some(HttpRequest {
236 method: meta.method,
237 path: meta.path,
238 headers: meta.headers,
239 body,
240 }),
241 Err(e) => {
242 // log-policy: system-broken
243 tracing::error!("failed to assemble stream body for accept-http: {e}");
244 None
245 }
246 };
247 Ok(request)
248 }
249
250 async fn submit_exchange(
251 accessor: &Accessor<SourceHostState, wasmtime::component::HasSelf<SourceHostState>>,
252 mut exchange: WasmExchange,
253 ) -> Result<SubmitOutcome, SourceWasmError> {
254 // Snapshot owned values (clones) + extract the stream (if any) inside a
255 // single `with`. The closure must return before `spawn` is called:
256 // `Accessor::spawn` internally calls `with`, which panics if nested
257 // (concurrent.rs:454). `take_stream` replaces a `WasmBody::Stream` with
258 // `Empty`, moving the live `StreamReader`/`FutureReader` out so the
259 // metadata conversion below does not trip over the non-cloneable handle.
260 let (exchange_tx, cancel_token, stream_parts) = accessor.with(|mut view| {
261 let state = view.get();
262 let exchange_tx = state.exchange_tx.clone();
263 let cancel_token = state.cancel_token.clone();
264 let stream_parts = exchange.take_stream();
265 (exchange_tx, cancel_token, stream_parts)
266 });
267 // `with` guard dropped here — safe to call `accessor.spawn` below.
268
269 // Build the native Exchange from the (now stream-stripped) metadata.
270 // The body is `Empty` for the stream case; it is overwritten with
271 // `Body::Stream` after the drain is spawned.
272 let mut native = source_exchange_to_native(exchange);
273 let (reply_tx, reply_rx) = oneshot::channel();
274
275 if let Some((stream_reader, terminal, stream_metadata)) = stream_parts {
276 // Streaming submit-exchange: fire-and-return.
277 //
278 // The guest emits a `stream<u8>` body. Reading a wasmtime stream
279 // requires the concurrent execution context (an `&Accessor`), which
280 // is only live while the event loop driving `run` polls. We CANNOT
281 // `tokio::spawn` a moved store + `run_concurrent` (the source
282 // world's `run` is already inside `run_concurrent`;
283 // `check_recursive_run` panics). Instead, `Accessor::spawn` queues
284 // the drain onto the SAME event loop, where it progresses
285 // concurrently with the guest fiber.
286 let (chunk_tx, chunk_rx) = mpsc::channel::<DrainEvent>(DEFAULT_DRAIN_CHANNEL_BOUND);
287 // Non-blocking terminal slot: written BEFORE sender drop so the
288 // consumer observes the terminal result after buffered chunks drain.
289 // Created ONCE and shared between the drain task and the body stream.
290 let source_terminal: TerminalSlot = Arc::new(std::sync::Mutex::new(None));
291 // progress/receiver_gone unused under source (no watchdog);
292 // required by shared drain_guest_stream signature.
293 let coord = DrainCoord {
294 cancel: cancel_token.clone(),
295 progress: Arc::new(Notify::new()),
296 receiver_gone: Arc::new(Notify::new()),
297 terminal_slot: source_terminal.clone(),
298 };
299
300 // Register the drain BEFORE sending the exchange. Ordering is
301 // load-bearing: the chunk-channel receiver only gets a consumer
302 // (the downstream `Body::Stream` reader) once the exchange is
303 // delivered. If we awaited drain completion before sending, the
304 // bounded chunk channel would fill, backpressure-stall the drain,
305 // and self-deadlock.
306 accessor.spawn(SubmitExchangeDrain {
307 stream_reader,
308 terminal,
309 chunk_tx,
310 coord,
311 });
312
313 // Attach the lazy body. Downstream reads from this receiver; the
314 // spawned drain feeds it from the guest's stream.
315 native.input.body = Body::Stream(StreamBody {
316 stream: Arc::new(tokio::sync::Mutex::new(Some(Box::pin(
317 receiver_to_body_stream(crate::return_stream::DrainReceiver {
318 rx: chunk_rx,
319 terminal: source_terminal,
320 }),
321 )))),
322 metadata: stream_metadata,
323 });
324 }
325
326 // Send the (native) exchange and await the pipeline's acceptance reply.
327 // submit-exchange returns once the pipeline ACCEPTS the envelope —
328 // before full body drain (fire-and-return). Race against cancellation
329 // so `stop()` unblocks a parked submit promptly.
330 let outcome = tokio::select! {
331 res = async {
332 if exchange_tx.send((native, reply_tx)).await.is_err() {
333 // Bridge gone (clean shutdown) — report Stopped.
334 return SubmitOutcome::Stopped;
335 }
336 // Pipeline bridge replies with the delivery outcome; a dropped
337 // reply (bridge exited after accept) is also a clean stop.
338 reply_rx.await.unwrap_or(SubmitOutcome::Stopped)
339 } => res,
340 _ = cancel_token.cancelled() => SubmitOutcome::Stopped,
341 };
342 Ok(outcome)
343 }
344}
345
346// ─── submit-exchange background drain (AccessorTask) ──────────────────────
347
348/// `AccessorTask` driving the guest→host body drain for a streaming
349/// `submit-exchange`.
350///
351/// Unlike the plugin/bean return path (`spawn_return_drain`), the source world
352/// CANNOT `tokio::spawn` a moved store and open a fresh `run_concurrent` — the
353/// source `run` is already inside `run_concurrent` and `check_recursive_run`
354/// panics on a nested call. `Accessor::spawn` instead registers this task on
355/// the SAME event loop already driving `run`, so the drain progresses
356/// concurrently with the guest fiber (and the guest's `spawn_local` writer).
357struct SubmitExchangeDrain {
358 stream_reader: wasmtime::component::StreamReader<u8>,
359 terminal: wasmtime::component::FutureReader<Result<(), SourceWasmError>>,
360 chunk_tx: mpsc::Sender<DrainEvent>,
361 coord: DrainCoord,
362}
363
364impl AccessorTask<SourceHostState, HasSelf<SourceHostState>> for SubmitExchangeDrain {
365 async fn run(
366 self,
367 accessor: &Accessor<SourceHostState, HasSelf<SourceHostState>>,
368 ) -> wasmtime::Result<()> {
369 drain_guest_stream::<SourceWasmError, SourceHostState>(
370 accessor,
371 self.stream_reader,
372 self.terminal,
373 self.chunk_tx,
374 self.coord,
375 )
376 .await;
377 // Terminal errors are surfaced through the chunk channel
378 // (`DrainEvent::Error`), not the task return — the downstream
379 // `Body::Stream` reader observes them. Returning `Ok` keeps the
380 // event loop's task accounting clean.
381 Ok(())
382 }
383}
384
385// ─── source WasmExchange → native Exchange conversion ──────────────────────
386
387/// Convert a (stream-stripped) source-world `WasmExchange` into a native
388/// [`Exchange`]. Inlines the `serde_bridge` field-mapping because the source
389/// bindings generate distinct types from the plugin bindings (TODO WIT-001).
390///
391/// A `WasmBody::Stream` never reaches here: `submit_exchange` calls
392/// `take_stream` (replacing it with `Empty`) before conversion and attaches a
393/// `Body::Stream` separately.
394fn source_exchange_to_native(wasm: WasmExchange) -> Exchange {
395 let input = source_message_to_native(wasm.input);
396 let mut exchange = Exchange::new(input);
397 // Preserve the guest's correlation_id (Exchange::new generates a random one).
398 if !wasm.correlation_id.is_empty() {
399 exchange.correlation_id = wasm.correlation_id;
400 }
401 if let Some(out) = wasm.output {
402 exchange.output = Some(source_message_to_native(out));
403 }
404 for (key, raw) in wasm.properties {
405 let value = serde_json::from_str::<Value>(&raw).unwrap_or(Value::String(raw));
406 exchange.properties.insert(key, value);
407 }
408 exchange.pattern = match wasm.pattern {
409 WasmPattern::InOnly => ExchangePattern::InOnly,
410 WasmPattern::InOut => ExchangePattern::InOut,
411 };
412 exchange
413}
414
415fn source_message_to_native(msg: WasmMessage) -> Message {
416 let headers = msg
417 .headers
418 .into_iter()
419 .map(|(k, raw)| {
420 let value = serde_json::from_str::<Value>(&raw).unwrap_or(Value::String(raw));
421 (k, value)
422 })
423 .collect();
424 Message {
425 headers,
426 body: source_body_to_native(msg.body),
427 }
428}
429
430fn source_body_to_native(body: WasmBody) -> Body {
431 match body {
432 WasmBody::Empty => Body::Empty,
433 WasmBody::Text(s) => Body::Text(s),
434 WasmBody::Bytes(v) => Body::Bytes(Bytes::from(v)),
435 WasmBody::Json(s) => serde_json::from_str::<Value>(&s)
436 .map(Body::Json)
437 .unwrap_or(Body::Text(s)),
438 WasmBody::Xml(s) => Body::Xml(s),
439 // Unreachable: `submit_exchange` extracts the stream via `take_stream`
440 // before conversion. Defensive guard (drop + warn) mirrors
441 // `serde_bridge::wasm_to_body`.
442 WasmBody::Stream(_) => {
443 tracing::warn!("source: undrained stream body reached conversion — dropped");
444 Body::Empty
445 }
446 }
447}
448
449// ─── Linker setup ─────────────────────────────────────────────────────────
450
451/// Register the source-host interface and WASI p2 into a linker for [`SourceHostState`].
452///
453/// Uses the ASYNCHRONOUS WASI linker: the guest's `run` export is async and is
454/// driven by `Store::run_concurrent` on a tokio task (see
455/// [`crate::source_consumer`]). Its async host imports (`accept-http`,
456/// `submit-exchange`) receive an `&Accessor` and use async channel ops.
457pub fn add_to_linker(linker: &mut Linker<SourceHostState>) -> Result<(), wasmtime::Error> {
458 // WASI p2 preview — async variant required for the async source world.
459 wasmtime_wasi::p2::add_to_linker_async(linker)?;
460
461 // Source-host interface (accept-http, submit-exchange, is-cancelled).
462 crate::source_bindings::camel::plugin::source_host::add_to_linker::<_, HasSelf<_>>(
463 linker,
464 |state| state,
465 )?;
466
467 Ok(())
468}
469
470// ─── HTTP listener task (async) ───────────────────────────────────────────
471
472/// Run an axum HTTP server that feeds incoming requests into `request_tx`.
473///
474/// The caller must have already bound the TCP listener (see
475/// [`crate::source_consumer::WasmSourceConsumer::start`]) so that a bind
476/// failure surfaces synchronously as a `start()` error rather than a
477/// background warning that leaves the route appearing healthy with no
478/// listener accepting requests.
479///
480/// Each request's body is streamed incrementally into a per-request bounded
481/// channel — the old 10 MiB `to_bytes` materialization cap is removed. The
482/// guest reads the body via the `stream-body-handle` returned by
483/// `accept-http`.
484///
485/// # Response timing (fire-and-forget semantic)
486///
487/// Returns **202 Accepted** once the request metadata is handed to the guest
488/// via the request channel. The body is streamed asynchronously *after* the
489/// response is sent — the handler does NOT wait for the full body before
490/// responding. This is inherent to the streaming shape: you cannot stream
491/// and wait for full receipt simultaneously. Mid-body connection drops
492/// surface as stream errors to the guest (the body channel receives an
493/// `Err` frame), not as HTTP-level failures to the client. The source
494/// accepts the request for processing; it does not guarantee full body
495/// receipt.
496///
497/// Shuts down gracefully when `cancel` is triggered.
498pub async fn run_http_listener(
499 listener: tokio::net::TcpListener,
500 path_filter: Option<String>,
501 request_tx: mpsc::Sender<RequestChannelItem>,
502 cancel: CancellationToken,
503) -> Result<(), CamelError> {
504 use axum::Router;
505 use axum::extract::State;
506 use axum::http::Request;
507 use axum::response::Response;
508 use axum::routing::any;
509 use http_body_util::BodyExt;
510
511 struct ListenerState {
512 tx: mpsc::Sender<RequestChannelItem>,
513 cancel: CancellationToken,
514 }
515
516 async fn handler(
517 State(state): State<Arc<ListenerState>>,
518 req: Request<axum::body::Body>,
519 ) -> Response {
520 let (parts, body) = req.into_parts();
521
522 // Per-request bounded channel for streaming the body to the guest.
523 // Capacity matches the drain-channel bound used elsewhere for
524 // guest→host streaming (return_stream.rs).
525 let (body_tx, body_rx) = mpsc::channel::<Result<Bytes, CamelError>>(
526 crate::return_stream::DEFAULT_DRAIN_CHANNEL_BOUND,
527 );
528
529 let http_meta = HttpMeta {
530 method: parts.method.to_string(),
531 path: parts.uri.path().to_string(),
532 headers: parts
533 .headers
534 .iter()
535 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
536 .collect(),
537 };
538
539 if state.tx.send((http_meta, body_rx)).await.is_err() {
540 return Response::builder()
541 .status(503)
542 .body(axum::body::Body::from("service unavailable"))
543 .unwrap(); // allow-unwrap
544 }
545
546 // Stream the axum body into body_tx incrementally. The spawned task
547 // owns the body and the sender; when the body is fully consumed (or
548 // errors), body_tx drops = EOF on the guest's StreamReader.
549 // Racing against cancel ensures the drain exits promptly on source
550 // shutdown rather than relying solely on connection-close propagation.
551 let drain_cancel = state.cancel.clone();
552 tokio::spawn(async move {
553 let mut body = body;
554 loop {
555 tokio::select! {
556 frame = body.frame() => {
557 match frame {
558 Some(Ok(frame)) => {
559 if let Ok(data) = frame.into_data()
560 && body_tx.send(Ok(data)).await.is_err()
561 {
562 // Guest stopped reading — abandon the stream.
563 break;
564 }
565 // Trailers frame: silently ignored (HTTP metadata, not body bytes).
566 }
567 Some(Err(e)) => {
568 let _ = body_tx
569 .send(Err(CamelError::Io(format!("body read: {e}"))))
570 .await;
571 break;
572 }
573 // None = clean EOF from hyper — body fully consumed.
574 None => break,
575 }
576 }
577 _ = drain_cancel.cancelled() => break,
578 }
579 }
580 // body_tx drops here → guest observes clean EOF.
581 });
582
583 Response::builder()
584 .status(202)
585 .body(axum::body::Body::from("accepted"))
586 .unwrap() // allow-unwrap
587 }
588
589 let state = Arc::new(ListenerState {
590 tx: request_tx,
591 cancel: cancel.clone(),
592 });
593
594 let route_path = path_filter
595 .filter(|path| !path.is_empty())
596 .map(|path| {
597 if path.starts_with('/') {
598 path
599 } else {
600 format!("/{path}")
601 }
602 })
603 .unwrap_or_else(|| "/{*path}".to_string());
604
605 let app = Router::new()
606 .route(&route_path, any(handler))
607 .with_state(state);
608
609 // Listener is already bound by the caller; capture the address for logging.
610 let local = listener.local_addr().ok();
611
612 if let Some(addr) = &local {
613 tracing::info!(%addr, "source HTTP listener started");
614 }
615
616 axum::serve(listener, app)
617 .with_graceful_shutdown(async move { cancel.cancelled().await })
618 .await
619 .map_err(|e| CamelError::Io(format!("HTTP listener error: {e}")))?;
620
621 if let Some(addr) = &local {
622 tracing::info!(%addr, "source HTTP listener stopped");
623 }
624 Ok(())
625}
626
627// ─── Pipeline bridge task (async) ─────────────────────────────────────────
628
629/// Receive native exchanges from the guest's `submit-exchange` import and
630/// forward them to the pipeline via [`ConsumerContext`].
631///
632/// The WasmExchange→native conversion (and streaming-body drain setup) happens
633/// inside the import now (see [`source_exchange_to_native`]), so the bridge is
634/// reduced to a pure forwarder. Sends [`SubmitOutcome::Accepted`] /
635/// [`SubmitOutcome::Stopped`] back to the guest via the oneshot reply channel.
636pub async fn run_pipeline_bridge(
637 mut exchange_rx: mpsc::Receiver<(Exchange, oneshot::Sender<SubmitOutcome>)>,
638 ctx: ConsumerContext,
639) -> Result<(), CamelError> {
640 while let Some((exchange, reply_tx)) = exchange_rx.recv().await {
641 let outcome = match ctx.send(exchange).await {
642 Ok(()) => SubmitOutcome::Accepted,
643 Err(_) => SubmitOutcome::Stopped,
644 };
645 // If the guest has already abandoned the exchange (unlikely), this
646 // send fails silently.
647 let _ = reply_tx.send(outcome);
648 }
649 Ok(())
650}
651
652// ─── Tests ────────────────────────────────────────────────────────────────
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657
658 #[test]
659 fn test_source_channels_new() {
660 let channels = SourceChannels::new();
661 assert!(!channels.request_tx.is_closed());
662 assert!(!channels.exchange_tx.is_closed());
663 }
664
665 #[test]
666 fn test_source_channels_default() {
667 let channels = SourceChannels::default();
668 assert!(!channels.request_tx.is_closed());
669 }
670
671 #[test]
672 fn test_request_channel_close_returns_none() {
673 let (tx, mut rx) = mpsc::channel::<RequestChannelItem>(1);
674 drop(tx);
675 let result = std::thread::spawn(move || rx.blocking_recv())
676 .join()
677 .unwrap();
678 assert!(result.is_none());
679 }
680
681 #[test]
682 fn test_exchange_channel_close_detected() {
683 let (tx, rx) = mpsc::channel::<(Exchange, oneshot::Sender<SubmitOutcome>)>(1);
684 drop(rx);
685 assert!(tx.is_closed());
686 }
687
688 #[test]
689 fn test_cancel_token_is_cancelled() {
690 let token = CancellationToken::new();
691 assert!(!token.is_cancelled());
692 token.cancel();
693 assert!(token.is_cancelled());
694 }
695
696 #[test]
697 fn test_submit_outcome_variants() {
698 let accepted = SubmitOutcome::Accepted;
699 let stopped = SubmitOutcome::Stopped;
700 assert!(matches!(accepted, SubmitOutcome::Accepted));
701 assert!(matches!(stopped, SubmitOutcome::Stopped));
702 }
703
704 #[test]
705 fn source_exchange_to_native_maps_fields() {
706 use crate::source_bindings::camel::plugin::types as src;
707 let wasm = src::WasmExchange {
708 input: src::WasmMessage {
709 headers: vec![("key".to_string(), "val".to_string())],
710 body: src::WasmBody::Text("hello".to_string()),
711 },
712 output: None,
713 properties: vec![("p".to_string(), "v".to_string())],
714 pattern: src::WasmPattern::InOnly,
715 correlation_id: "corr-1".to_string(),
716 route_id: Some("route-1".to_string()),
717 message_id: Some("msg-1".to_string()),
718 };
719
720 let native = source_exchange_to_native(wasm);
721 assert_eq!(native.input.headers.len(), 1);
722 assert_eq!(
723 native.input.headers.get("key"),
724 Some(&camel_api::Value::String("val".to_string()))
725 );
726 assert!(matches!(native.input.body, Body::Text(_)));
727 assert_eq!(
728 native.properties.get("p"),
729 Some(&camel_api::Value::String("v".to_string()))
730 );
731 assert!(matches!(native.pattern, ExchangePattern::InOnly));
732 assert_eq!(native.correlation_id, "corr-1");
733 }
734}