Skip to main content

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}