use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc;
use wasmtime::{Caller, Linker};
use crate::abi::AbiError;
pub const MAX_CHUNK_BYTES: usize = 64 * 1024;
pub const MAX_TOTAL_STREAM_BYTES: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct StreamingContext {
sender: Option<Arc<mpsc::Sender<Vec<u8>>>>,
bytes_emitted: Arc<AtomicU64>,
max_total: u64,
}
impl StreamingContext {
pub fn disabled() -> Self {
Self {
sender: None,
bytes_emitted: Arc::new(AtomicU64::new(0)),
max_total: 0,
}
}
pub fn with_channel(sender: mpsc::Sender<Vec<u8>>) -> Self {
Self::with_channel_and_cap(sender, MAX_TOTAL_STREAM_BYTES)
}
pub fn with_channel_and_cap(sender: mpsc::Sender<Vec<u8>>, max_total: u64) -> Self {
Self {
sender: Some(Arc::new(sender)),
bytes_emitted: Arc::new(AtomicU64::new(0)),
max_total,
}
}
pub async fn emit_chunk(&self, bytes: Vec<u8>) -> i32 {
let Some(s) = &self.sender else {
return -1;
};
let added = bytes.len() as u64;
let new_total = self.bytes_emitted.fetch_add(added, Ordering::SeqCst) + added;
if new_total > self.max_total {
self.bytes_emitted.fetch_sub(added, Ordering::SeqCst);
return -2;
}
match s.send(bytes).await {
Ok(_) => 0,
Err(_) => {
self.bytes_emitted.fetch_sub(added, Ordering::SeqCst);
-3
}
}
}
pub fn flush(&self) -> i32 {
if self.sender.is_none() {
return -1;
}
0
}
pub fn bytes_emitted(&self) -> u64 {
self.bytes_emitted.load(Ordering::SeqCst)
}
pub fn is_enabled(&self) -> bool {
self.sender.is_some()
}
}
pub trait HasStreaming {
fn streaming(&self) -> &StreamingContext;
}
pub const STREAMING_MODULE: &str = "wasi:tensor/host@0.1.0";
pub const FN_EMIT_CHUNK: &str = "emit-chunk";
pub const FN_FLUSH: &str = "flush";
pub const FN_INPUT_LEN: &str = "input-len";
pub const FN_READ_INPUT: &str = "read-input";
pub fn add_streaming_to_linker<T: HasStreaming + Send + 'static>(
linker: &mut Linker<T>,
) -> wasmtime::Result<()> {
linker.func_wrap_async(
STREAMING_MODULE,
FN_EMIT_CHUNK,
|mut caller: Caller<'_, T>,
(buf_ptr, buf_len): (i32, i32)|
-> Box<dyn std::future::Future<Output = i32> + Send + '_> {
let prep = prepare_emit_chunk(&mut caller, buf_ptr, buf_len);
Box::new(async move {
match prep {
Ok((bytes, ctx)) => ctx.emit_chunk(bytes).await,
Err(code) => code,
}
})
},
)?;
linker.func_wrap(STREAMING_MODULE, FN_FLUSH, |caller: Caller<'_, T>| -> i32 {
caller.data().streaming().flush()
})?;
Ok(())
}
fn prepare_emit_chunk<T: HasStreaming>(
caller: &mut Caller<'_, T>,
buf_ptr: i32,
buf_len: i32,
) -> Result<(Vec<u8>, StreamingContext), i32> {
if buf_len < 0 || buf_ptr < 0 {
return Err(-2);
}
if (buf_len as usize) > MAX_CHUNK_BYTES {
return Err(-2);
}
let memory = caller
.get_export("memory")
.and_then(|e| e.into_memory())
.ok_or(-2_i32)?;
let start = buf_ptr as usize;
let end = start.checked_add(buf_len as usize).ok_or(-2_i32)?;
let bytes: Vec<u8> = {
let data = memory.data(&caller);
if end > data.len() {
return Err(-2);
}
data[start..end].to_vec()
};
let ctx = caller.data().streaming().clone();
Ok((bytes, ctx))
}
#[derive(Debug, Clone, Default)]
pub struct InputContext {
bytes: Arc<[u8]>,
}
impl InputContext {
pub fn empty() -> Self {
Self::default()
}
pub fn new(bytes: impl Into<Arc<[u8]>>) -> Self {
Self {
bytes: bytes.into(),
}
}
pub fn bytes(&self) -> &[u8] {
&self.bytes
}
pub fn len_u32(&self) -> u32 {
u32::try_from(self.bytes.len()).unwrap_or(u32::MAX)
}
pub fn is_empty(&self) -> bool {
self.bytes.is_empty()
}
}
pub trait HasInput {
fn input(&self) -> &InputContext;
}
pub fn add_input_to_linker<T: HasInput + Send + 'static>(
linker: &mut Linker<T>,
) -> wasmtime::Result<()> {
linker.func_wrap(
STREAMING_MODULE,
FN_INPUT_LEN,
|caller: Caller<'_, T>| -> u32 { caller.data().input().len_u32() },
)?;
linker.func_wrap(
STREAMING_MODULE,
FN_READ_INPUT,
|mut caller: Caller<'_, T>, ptr: u32, len: u32| -> i32 {
let input = caller.data().input().clone();
let staged = input.bytes();
if staged.is_empty() || len == 0 {
return 0;
}
let to_copy = std::cmp::min(staged.len(), len as usize);
let memory = match caller.get_export("memory").and_then(|e| e.into_memory()) {
Some(m) => m,
None => return AbiError::InvalidPointer.code(),
};
let start = ptr as usize;
let end = match start.checked_add(to_copy) {
Some(e) => e,
None => return AbiError::InvalidPointer.code(),
};
let mem_len = memory.data(&caller).len();
if end > mem_len {
return AbiError::InvalidPointer.code();
}
let buf = staged[..to_copy].to_vec();
if memory.write(&mut caller, start, &buf).is_err() {
return AbiError::InvalidPointer.code();
}
i32::try_from(to_copy).unwrap_or(i32::MAX)
},
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn disabled_context_returns_minus_one() {
let ctx = StreamingContext::disabled();
assert!(!ctx.is_enabled());
assert_eq!(ctx.emit_chunk(vec![1, 2, 3]).await, -1);
assert_eq!(ctx.flush(), -1);
assert_eq!(ctx.bytes_emitted(), 0);
}
#[tokio::test]
async fn enabled_context_forwards_bytes() {
let (tx, mut rx) = mpsc::channel::<Vec<u8>>(4);
let ctx = StreamingContext::with_channel(tx);
assert!(ctx.is_enabled());
assert_eq!(ctx.emit_chunk(vec![0xAA, 0xBB, 0xCC]).await, 0);
assert_eq!(ctx.bytes_emitted(), 3);
let chunk = rx.recv().await.expect("chunk delivered");
assert_eq!(chunk, vec![0xAA, 0xBB, 0xCC]);
assert_eq!(ctx.flush(), 0);
}
#[test]
fn input_context_empty_by_default() {
let ctx = InputContext::empty();
assert!(ctx.is_empty());
assert_eq!(ctx.len_u32(), 0);
assert_eq!(ctx.bytes(), b"");
assert!(InputContext::default().is_empty());
}
#[test]
fn input_context_carries_staged_bytes() {
let ctx = InputContext::new(b"hello prompt".to_vec());
assert!(!ctx.is_empty());
assert_eq!(ctx.len_u32(), 12);
assert_eq!(ctx.bytes(), b"hello prompt");
let cloned = ctx.clone();
assert_eq!(cloned.bytes(), ctx.bytes());
}
#[test]
fn streaming_module_is_versioned() {
assert!(STREAMING_MODULE.contains("wasi:tensor"));
assert!(STREAMING_MODULE.ends_with("@0.1.0"));
}
#[test]
fn streaming_module_version_matches_wit_package_decl() {
const WIT: &str = include_str!("../wit/wasi-tensor.wit");
let pkg_line = WIT
.lines()
.find(|l| l.trim_start().starts_with("package wasi:tensor@"))
.expect("wit/wasi-tensor.wit must declare `package wasi:tensor@x.y.z;`");
let version = pkg_line
.trim()
.trim_start_matches("package wasi:tensor@")
.trim_end_matches(';')
.trim();
assert!(
!version.is_empty(),
"could not parse a version out of: {pkg_line:?}"
);
let expected_suffix = format!("@{version}");
assert!(
STREAMING_MODULE.ends_with(&expected_suffix),
"STREAMING_MODULE ({STREAMING_MODULE:?}) drifted from \
wit/wasi-tensor.wit package version ({version:?}); update \
src/streaming.rs::STREAMING_MODULE or the WIT file so they agree."
);
}
}