use bytes::Bytes;
use camel_api::{CamelError, StreamBody, StreamMetadata};
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use futures::stream::BoxStream;
use tokio::sync::{Notify, oneshot};
use tokio_util::sync::CancellationToken;
use wasmtime::StoreContextMut;
use wasmtime::component::{
Accessor, Destination, FutureReader, StreamProducer, StreamReader, StreamResult, VecBuffer,
};
use crate::bindings::camel::plugin::types::{StreamBodyHandle, WasmBody, WasmError};
#[cfg(test)]
use crate::runtime::WasmHostState;
pub(crate) struct BoxStreamProducer<S> {
stream: Option<BoxStream<'static, Result<Bytes, CamelError>>>,
progress_notify: Arc<Notify>,
terminal_tx: Option<oneshot::Sender<Option<String>>>,
cancel: CancellationToken,
max_bytes: u64,
written: u64,
_marker: std::marker::PhantomData<S>,
}
impl<S> BoxStreamProducer<S> {
pub(crate) fn new(
stream: Option<BoxStream<'static, Result<Bytes, CamelError>>>,
progress_notify: Arc<Notify>,
terminal_tx: oneshot::Sender<Option<String>>,
cancel: CancellationToken,
max_bytes: u64,
) -> Self {
Self {
stream,
progress_notify,
terminal_tx: Some(terminal_tx),
cancel,
max_bytes,
written: 0,
_marker: std::marker::PhantomData,
}
}
fn finish_terminal(&mut self, outcome: Option<String>) {
if let Some(tx) = self.terminal_tx.take() {
let _ = tx.send(outcome);
}
}
}
impl<S> Unpin for BoxStreamProducer<S> {}
impl<S: Send + 'static> StreamProducer<S> for BoxStreamProducer<S> {
type Item = u8;
type Buffer = VecBuffer<u8>;
fn poll_produce<'a>(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
_store: StoreContextMut<'a, S>,
mut destination: Destination<'a, u8, VecBuffer<u8>>,
finish: bool,
) -> Poll<wasmtime::Result<StreamResult>> {
if self.cancel.is_cancelled() {
self.finish_terminal(None);
return Poll::Ready(Ok(StreamResult::Dropped));
}
if finish {
self.finish_terminal(None);
return Poll::Ready(Ok(StreamResult::Cancelled));
}
let next = match self.stream.as_mut() {
Some(stream) => stream.as_mut().poll_next(cx),
None => {
self.finish_terminal(None);
return Poll::Ready(Ok(StreamResult::Dropped));
}
};
match next {
Poll::Pending => Poll::Pending,
Poll::Ready(Some(Ok(bytes))) => {
self.written += bytes.len() as u64;
if self.written > self.max_bytes {
let msg = format!("stream exceeded max-bytes ({})", self.max_bytes);
self.finish_terminal(Some(msg));
return Poll::Ready(Ok(StreamResult::Dropped));
}
destination.set_buffer(bytes.to_vec().into());
self.progress_notify.notify_one();
Poll::Ready(Ok(StreamResult::Completed))
}
Poll::Ready(None) => {
self.stream.take();
self.finish_terminal(None);
Poll::Ready(Ok(StreamResult::Dropped))
}
Poll::Ready(Some(Err(err))) => {
self.stream.take();
self.finish_terminal(Some(err.to_string()));
Poll::Ready(Ok(StreamResult::Dropped))
}
}
}
}
pub(crate) async fn extract_stream_body(
body: StreamBody,
) -> (
Option<BoxStream<'static, Result<Bytes, CamelError>>>,
StreamMetadata,
) {
let stream = body.stream.lock().await.take();
(stream, body.metadata)
}
#[allow(clippy::type_complexity)] fn build_stream_readers<E, S, U>(
accessor: &Accessor<S, U>,
stream: BoxStream<'static, Result<Bytes, CamelError>>,
cancel: CancellationToken,
max_bytes: u64,
progress_notify: Arc<Notify>,
mk_err: impl FnOnce(String) -> E + Send + 'static,
) -> wasmtime::Result<(StreamReader<u8>, FutureReader<Result<(), E>>)>
where
E: Send + Sync + 'static,
S: Send + 'static,
U: wasmtime::component::HasData + Send + 'static,
E: wasmtime::component::Lower + wasmtime::component::Lift,
{
let (terminal_tx, terminal_rx) = oneshot::channel::<Option<String>>();
let producer = BoxStreamProducer::new(
Some(stream),
progress_notify,
terminal_tx,
cancel,
max_bytes,
);
let stream_reader = accessor.with(|mut access| StreamReader::new(&mut access, producer))?;
let terminal_reader = accessor.with(|mut access| {
FutureReader::new(&mut access, async move {
let value: Result<(), E> = match terminal_rx.await {
Ok(Some(msg)) => Err(mk_err(msg)),
Ok(None) => Ok(()),
Err(_) => Ok(()),
};
Ok::<_, wasmtime::Error>(value)
})
})?;
Ok((stream_reader, terminal_reader))
}
pub(crate) fn assemble_stream_body<S: Send + 'static>(
accessor: &Accessor<S>,
stream: BoxStream<'static, Result<Bytes, CamelError>>,
metadata: &StreamMetadata,
cancel: CancellationToken,
max_bytes: u64,
progress_notify: Arc<Notify>,
) -> wasmtime::Result<WasmBody> {
let (stream_reader, terminal_reader) = build_stream_readers(
accessor,
stream,
cancel,
max_bytes,
progress_notify,
WasmError::ProcessorError,
)?;
Ok(WasmBody::Stream(StreamBodyHandle {
r#stream: stream_reader,
terminal: terminal_reader,
size_hint: metadata.size_hint,
content_type: metadata.content_type.clone(),
origin: metadata.origin.clone(),
}))
}
pub(crate) fn assemble_stream_body_bean<S: Send + 'static>(
accessor: &Accessor<S>,
stream: BoxStream<'static, Result<Bytes, CamelError>>,
metadata: &StreamMetadata,
cancel: CancellationToken,
max_bytes: u64,
progress_notify: Arc<Notify>,
) -> wasmtime::Result<crate::bean_bindings::camel::plugin::types::WasmBody> {
use crate::bean_bindings::camel::plugin::types::{StreamBodyHandle, WasmBody, WasmError};
let (stream_reader, terminal_reader) = build_stream_readers(
accessor,
stream,
cancel,
max_bytes,
progress_notify,
WasmError::ProcessorError,
)?;
Ok(WasmBody::Stream(StreamBodyHandle {
r#stream: stream_reader,
terminal: terminal_reader,
size_hint: metadata.size_hint,
content_type: metadata.content_type.clone(),
origin: metadata.origin.clone(),
}))
}
pub(crate) fn assemble_stream_body_source<S, U>(
accessor: &Accessor<S, U>,
stream: BoxStream<'static, Result<Bytes, CamelError>>,
cancel: CancellationToken,
max_bytes: u64,
) -> wasmtime::Result<crate::source_bindings::camel::plugin::types::StreamBodyHandle>
where
S: Send + 'static,
U: wasmtime::component::HasData + Send + 'static,
{
use crate::source_bindings::camel::plugin::types::WasmError;
let progress_notify = Arc::new(Notify::new());
let (stream_reader, terminal_reader) = build_stream_readers(
accessor,
stream,
cancel,
max_bytes,
progress_notify,
WasmError::ProcessorError,
)?;
Ok(
crate::source_bindings::camel::plugin::types::StreamBodyHandle {
r#stream: stream_reader,
terminal: terminal_reader,
size_hint: None,
content_type: None,
origin: None,
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bindings::camel::plugin::types::WasmBody;
use camel_api::StreamMetadata;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::test]
async fn test_extract_stream_body_takes_stream_once() {
use futures::stream;
let chunks: Vec<Result<Bytes, CamelError>> = vec![
Ok(Bytes::from_static(b"abc")),
Ok(Bytes::from_static(b"de")),
];
let body = StreamBody {
stream: Arc::new(Mutex::new(Some(Box::pin(stream::iter(chunks))))),
metadata: StreamMetadata {
size_hint: Some(5),
content_type: Some("text/plain".into()),
origin: Some("test://origin".into()),
},
};
let clone = body.clone();
let (stream, metadata) = extract_stream_body(body).await;
assert!(stream.is_some(), "first extraction must yield the stream");
assert_eq!(metadata.size_hint, Some(5));
assert_eq!(metadata.content_type.as_deref(), Some("text/plain"));
assert_eq!(metadata.origin.as_deref(), Some("test://origin"));
let (again, _) = extract_stream_body(clone).await;
assert!(again.is_none(), "second extraction must observe None");
}
#[tokio::test]
async fn test_assemble_stream_body_and_wasm_to_body_roundtrip() {
use crate::runtime::WasmHostState;
use camel_core::Registry;
use futures::stream;
use wasmtime::{AsContextMut, Config, Engine, Store};
let mut config = Config::new();
config.wasm_component_model(true);
config.concurrency_support(true);
let engine = Engine::new(&config).expect("engine");
let state = WasmHostState {
table: wasmtime::component::ResourceTable::new(),
wasi: wasmtime_wasi::WasiCtxBuilder::new()
.inherit_stderr()
.build(),
properties: HashMap::new(),
registry: Arc::new(std::sync::Mutex::new(Registry::new())),
call_depth: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
limits: wasmtime::StoreLimits::default(),
state_store: crate::state_store::StateStore::new(),
capabilities: crate::capabilities::WasmCapabilities::default(),
};
let mut store = Store::new(&engine, state);
let chunks: Vec<Result<Bytes, CamelError>> = vec![Ok(Bytes::from_static(b"payload"))];
let stream =
Box::pin(stream::iter(chunks)) as BoxStream<'static, Result<Bytes, CamelError>>;
let metadata = StreamMetadata {
content_type: Some("application/octet-stream".into()),
..StreamMetadata::default()
};
let cancel = CancellationToken::new();
let notify = Arc::new(tokio::sync::Notify::new());
let body_result = store
.as_context_mut()
.run_concurrent(async |accessor| {
assemble_stream_body(accessor, stream, &metadata, cancel, 1024, notify)
})
.await;
let wasm_body = body_result
.expect("run_concurrent should not fail")
.expect("assemble_stream_body should succeed");
assert!(matches!(wasm_body, WasmBody::Stream(_)));
assert!(matches!(wasm_body, WasmBody::Stream(_)));
}
struct CollectConsumer {
items: Arc<std::sync::Mutex<Vec<u8>>>,
}
impl CollectConsumer {
fn new() -> Self {
Self {
items: Arc::new(std::sync::Mutex::new(Vec::new())),
}
}
fn shared() -> (Self, Arc<std::sync::Mutex<Vec<u8>>>) {
let items = Arc::new(std::sync::Mutex::new(Vec::new()));
(
Self {
items: items.clone(),
},
items,
)
}
}
impl wasmtime::component::StreamConsumer<WasmHostState> for CollectConsumer {
type Item = u8;
fn poll_consume(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
store: StoreContextMut<'_, WasmHostState>,
mut source: wasmtime::component::Source<'_, Self::Item>,
_finish: bool,
) -> Poll<wasmtime::Result<StreamResult>> {
let mut buf = Vec::with_capacity(65536);
source
.read(store, &mut buf)
.expect("CollectConsumer read should not fail");
self.get_mut().items.lock().unwrap().extend(buf);
Poll::Ready(Ok(StreamResult::Completed))
}
}
#[allow(clippy::type_complexity)]
fn make_producer_store(
stream: BoxStream<'static, Result<Bytes, CamelError>>,
cancel: CancellationToken,
max_bytes: u64,
) -> (
wasmtime::Engine,
wasmtime::Store<WasmHostState>,
oneshot::Receiver<Option<String>>,
Arc<Notify>,
BoxStreamProducer<WasmHostState>,
) {
use camel_core::Registry;
use wasmtime::{Config, Engine, Store};
let mut config = Config::new();
config.wasm_component_model(true);
config.concurrency_support(true);
let engine = Engine::new(&config).expect("engine");
let state = WasmHostState {
table: wasmtime::component::ResourceTable::new(),
wasi: wasmtime_wasi::WasiCtxBuilder::new()
.inherit_stderr()
.build(),
properties: HashMap::new(),
registry: Arc::new(std::sync::Mutex::new(Registry::new())),
call_depth: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
limits: wasmtime::StoreLimits::default(),
state_store: crate::state_store::StateStore::new(),
capabilities: crate::capabilities::WasmCapabilities::default(),
};
let store = Store::new(&engine, state);
let notify = Arc::new(Notify::new());
let (terminal_tx, terminal_rx) = oneshot::channel::<Option<String>>();
let producer =
BoxStreamProducer::new(Some(stream), notify.clone(), terminal_tx, cancel, max_bytes);
(engine, store, terminal_rx, notify, producer)
}
#[tokio::test]
async fn test_poll_produce_normal_flow() {
use futures::stream;
use wasmtime::AsContextMut;
let chunks: Vec<Result<Bytes, CamelError>> = vec![
Ok(Bytes::from_static(b"hello")),
Ok(Bytes::from_static(b"world")),
];
let stream =
Box::pin(stream::iter(chunks)) as BoxStream<'static, Result<Bytes, CamelError>>;
let cancel = CancellationToken::new();
let (_engine, mut store, terminal_rx, notify, producer) =
make_producer_store(stream, cancel, 1024);
let (consumer, collected) = CollectConsumer::shared();
let outcome = store
.as_context_mut()
.run_concurrent(async |accessor| {
accessor.with(|mut access| {
let stream_reader =
StreamReader::new(&mut access, producer).expect("StreamReader::new");
stream_reader.pipe(&mut access, consumer).expect("pipe");
Ok::<_, wasmtime::Error>(())
})?;
terminal_rx
.await
.map_err(|_| wasmtime::Error::msg("terminal_rx dropped"))
})
.await;
let terminal = outcome.expect("run_concurrent ok").expect("terminal ok");
assert!(
terminal.is_none(),
"expected clean completion, got {:?}",
terminal
);
{
let bytes = collected.lock().unwrap();
assert_eq!(&*bytes, b"helloworld", "expected both chunks");
}
use std::time::Duration;
assert!(
tokio::time::timeout(Duration::from_millis(50), notify.notified())
.await
.is_ok(),
"progress notify should have been fired"
);
}
#[tokio::test]
async fn test_poll_produce_error_flow() {
use futures::stream;
use wasmtime::AsContextMut;
let chunks: Vec<Result<Bytes, CamelError>> = vec![
Ok(Bytes::from_static(b"ok")),
Err(CamelError::ProcessorError("test error".into())),
];
let stream =
Box::pin(stream::iter(chunks)) as BoxStream<'static, Result<Bytes, CamelError>>;
let cancel = CancellationToken::new();
let (_engine, mut store, terminal_rx, notify, producer) =
make_producer_store(stream, cancel, 1024);
let outcome = store
.as_context_mut()
.run_concurrent(async |accessor| {
accessor.with(|mut access| {
let stream_reader =
StreamReader::new(&mut access, producer).expect("StreamReader::new");
stream_reader
.pipe(&mut access, CollectConsumer::new())
.expect("pipe");
Ok::<_, wasmtime::Error>(())
})?;
terminal_rx
.await
.map_err(|_| wasmtime::Error::msg("terminal_rx dropped"))
})
.await;
let terminal = outcome.expect("run_concurrent ok").expect("terminal ok");
assert!(
terminal
.as_deref()
.unwrap_or_default()
.contains("test error"),
"expected error containing 'test error', got {:?}",
terminal
);
use std::time::Duration;
assert!(
tokio::time::timeout(Duration::from_millis(50), notify.notified())
.await
.is_ok(),
"progress notify should have been fired for shipped chunk"
);
}
#[tokio::test]
async fn test_poll_produce_max_bytes_overflow() {
use futures::stream;
use wasmtime::AsContextMut;
let chunks: Vec<Result<Bytes, CamelError>> = vec![
Ok(Bytes::from_static(b"hello")), Ok(Bytes::from_static(b"world")), ];
let stream =
Box::pin(stream::iter(chunks)) as BoxStream<'static, Result<Bytes, CamelError>>;
let cancel = CancellationToken::new();
let (_engine, mut store, terminal_rx, notify, producer) =
make_producer_store(stream, cancel, 6);
let outcome = store
.as_context_mut()
.run_concurrent(async |accessor| {
accessor.with(|mut access| {
let stream_reader =
StreamReader::new(&mut access, producer).expect("StreamReader::new");
stream_reader
.pipe(&mut access, CollectConsumer::new())
.expect("pipe");
Ok::<_, wasmtime::Error>(())
})?;
terminal_rx
.await
.map_err(|_| wasmtime::Error::msg("terminal_rx dropped"))
})
.await;
let terminal = outcome.expect("run_concurrent ok").expect("terminal ok");
let msg = terminal.expect("expected overflow error");
assert!(msg.contains("max-bytes"), "overflow msg: {msg}");
use std::time::Duration;
assert!(
tokio::time::timeout(Duration::from_millis(50), notify.notified())
.await
.is_ok(),
"progress notify should have been fired before overflow"
);
}
#[tokio::test]
async fn test_poll_produce_cancelled_token() {
use futures::stream;
use wasmtime::AsContextMut;
let chunks: Vec<Result<Bytes, CamelError>> = vec![Ok(Bytes::from_static(b"data"))];
let stream =
Box::pin(stream::iter(chunks)) as BoxStream<'static, Result<Bytes, CamelError>>;
let cancel = CancellationToken::new();
cancel.cancel();
let (_engine, mut store, terminal_rx, notify, producer) =
make_producer_store(stream, cancel, 1024);
let outcome = store
.as_context_mut()
.run_concurrent(async |accessor| {
accessor.with(|mut access| {
let stream_reader =
StreamReader::new(&mut access, producer).expect("StreamReader::new");
stream_reader
.pipe(&mut access, CollectConsumer::new())
.expect("pipe");
Ok::<_, wasmtime::Error>(())
})?;
terminal_rx
.await
.map_err(|_| wasmtime::Error::msg("terminal_rx dropped"))
})
.await;
let terminal = outcome.expect("run_concurrent ok").expect("terminal ok");
assert!(
terminal.is_none(),
"expected no error for cancel, got {:?}",
terminal
);
use std::time::Duration;
assert!(
tokio::time::timeout(Duration::from_millis(50), notify.notified())
.await
.is_err(),
"progress notify should NOT have been fired for cancelled stream"
);
}
}