Skip to main content

fidius_guest/
stream_ffi.rs

1// Copyright 2026 Colliery, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! cdylib server-streaming FFI handle (FIDIUS-I-0026 Phase 3 / CS.1, T-0138).
16//!
17//! A `-> fidius::Stream<T>` method on the **cdylib** backend can't be a single
18//! unary call. Instead its vtable slot holds an *init* shim (with the ordinary
19//! `(in_ptr, in_len, *mut *mut u8, *mut u32) -> i32` signature, so the vtable
20//! layout is unchanged) that returns — via the standard `out_ptr` slot — a
21//! pointer to a [`FidiusStreamHandle`]. The host then pulls the stream with an
22//! **arena-style `next`** (FIDIUS-T-0138): the host passes a *reusable* buffer it
23//! owns, and the guest writes the bincode-encoded item into it — no per-item
24//! heap alloc, and no `free_buffer` FFI crossing.
25//!
26//! ```text
27//! status = (handle.next)(handle, buf_ptr, buf_cap, &mut out_len);
28//!   STATUS_OK                → one item: buf[..out_len] is the bincode-encoded item.
29//!   STATUS_STREAM_END        → clean end of stream (no item).
30//!   STATUS_BUFFER_TOO_SMALL  → out_len = required size; host grows + retries once.
31//!                              (The guest holds the serialized item across the
32//!                              retry, so no item is lost.)
33//!   STATUS_PLUGIN_ERROR      → buf[..out_len] is a bincode `PluginError`.
34//!   STATUS_SERIALIZATION_ERROR / STATUS_PANIC → as usual.
35//! (handle.drop_fn)(handle);  // run once: drops the producer + frees the handle
36//! ```
37//!
38//! Items cross as **concrete bincode of the item type** — byte-identical to the
39//! unary cdylib wire (FIDIUS-T-0137). The host decodes each item with a
40//! caller-supplied `bincode::<O>` decoder (the typed Client knows `O`).
41
42use core::ffi::c_void;
43use core::marker::PhantomData;
44
45use serde::de::DeserializeOwned;
46use serde::Serialize;
47
48/// Per-stream handle returned by a cdylib streaming method's init shim. See the
49/// module docs for the pull protocol. `#[repr(C)]` so the guest (which builds it)
50/// and the host (which drives it) agree on the layout across the FFI boundary.
51#[repr(C)]
52pub struct FidiusStreamHandle {
53    /// Advance one item into a host-provided buffer.
54    /// `(handle, buf_ptr, buf_cap, &mut out_len) -> status`.
55    pub next: unsafe extern "C" fn(*mut FidiusStreamHandle, *mut u8, u32, *mut u32) -> i32,
56    /// Finish/cancel: drops the producer and frees the handle box. Call exactly once.
57    pub drop_fn: unsafe extern "C" fn(*mut FidiusStreamHandle),
58    /// Opaque pointer to the boxed [`StreamState<T>`], owned by the guest; freed
59    /// by `drop_fn`.
60    pub state: *mut c_void,
61}
62
63/// Outcome of [`StreamState::next_into`] — mapped to FFI status codes by the
64/// macro-generated `next` shim.
65pub enum NextStatus {
66    /// An item was written; payload is `buf[..n]`.
67    Item(usize),
68    /// No more items.
69    End,
70    /// The buffer was too small; `usize` is the size required. The item is
71    /// retained for the next call (no item lost on grow-and-retry).
72    TooSmall(usize),
73    /// Serializing the item failed.
74    SerErr,
75}
76
77/// Guest-side driver for an arena-style cdylib stream (FIDIUS-T-0138). Wraps the
78/// producer [`Stream<T>`](crate::stream_marker::Stream) and holds the *current
79/// item value* (not its bytes), so a `BUFFER_TOO_SMALL` retry re-serializes the
80/// same item rather than advancing the iterator (which would drop it) — and so
81/// the happy path serializes **directly into the host buffer with no `Vec`
82/// allocation**.
83pub struct StreamState<T> {
84    stream: crate::stream_marker::Stream<T>,
85    /// The current item awaiting delivery; `None` between items.
86    pending: Option<T>,
87}
88
89impl<T: Serialize> StreamState<T> {
90    /// Wrap a producer stream.
91    pub fn new(stream: crate::stream_marker::Stream<T>) -> Self {
92        Self {
93            stream,
94            pending: None,
95        }
96    }
97
98    /// Pull the next item (if needed) and serialize it **directly into `buf`** —
99    /// no intermediate allocation. On `TooSmall` the item value is retained and
100    /// re-serialized on the next (larger-buffer) call, so nothing is lost.
101    pub fn next_into(&mut self, buf: &mut [u8]) -> NextStatus {
102        if self.pending.is_none() {
103            match self.stream.next_item() {
104                Some(item) => self.pending = Some(item),
105                None => return NextStatus::End,
106            }
107        }
108        // SAFETY of unwrap: pending is Some by the block above.
109        let item = self.pending.as_ref().unwrap();
110        let size = match crate::wire::serialized_size(item) {
111            Ok(s) => s as usize,
112            Err(_) => return NextStatus::SerErr,
113        };
114        if size > buf.len() {
115            return NextStatus::TooSmall(size);
116        }
117        if crate::wire::serialize_into(&mut buf[..size], item).is_err() {
118            return NextStatus::SerErr;
119        }
120        self.pending = None;
121        NextStatus::Item(size)
122    }
123}
124
125/// Guest-side **consumer** of a host-produced stream — the client-streaming
126/// counterpart of [`StreamState`] (FIDIUS-I-0030 / ADR-0007). For a method that
127/// takes a `Stream<T>` argument, the host fills a [`FidiusStreamHandle`] from its
128/// producer and the guest pulls items by calling `next` and bincode-deserializing
129/// each into `T`. Yields items via [`Iterator`]; dropping it runs the host
130/// producer's `drop_fn` (cancel).
131pub struct HostStream<T> {
132    handle: *mut FidiusStreamHandle,
133    /// Grow hint carried across calls so a `TooSmall` retry uses a big-enough buffer.
134    cap: usize,
135    _marker: PhantomData<T>,
136}
137
138impl<T: DeserializeOwned> HostStream<T> {
139    /// Wrap a host-provided handle. The handle is owned by this consumer and freed
140    /// (via `drop_fn`) on drop.
141    ///
142    /// # Safety
143    /// `handle` must be a valid, exclusively-owned `FidiusStreamHandle` supplied by
144    /// the host for the duration of this consumer.
145    pub unsafe fn from_handle(handle: *mut FidiusStreamHandle) -> Self {
146        Self {
147            handle,
148            cap: 256,
149            _marker: PhantomData,
150        }
151    }
152
153    fn pull(&mut self) -> Option<T> {
154        let mut buf = vec![0u8; self.cap];
155        loop {
156            let mut out_len: u32 = 0;
157            // SAFETY: `handle` is valid; `buf` has `self.cap` writable bytes; `next`
158            // writes at most `cap` and reports the count in `out_len`.
159            let status = unsafe {
160                ((*self.handle).next)(self.handle, buf.as_mut_ptr(), self.cap as u32, &mut out_len)
161            };
162            match status {
163                crate::status::STATUS_OK => {
164                    return crate::wire::deserialize::<T>(&buf[..out_len as usize]).ok();
165                }
166                crate::status::STATUS_BUFFER_TOO_SMALL => {
167                    self.cap = (out_len as usize).max(self.cap * 2);
168                    buf = vec![0u8; self.cap];
169                }
170                // STREAM_END / PLUGIN_ERROR → end of stream for the consumer.
171                _ => return None,
172            }
173        }
174    }
175}
176
177impl<T: DeserializeOwned> Iterator for HostStream<T> {
178    type Item = T;
179    fn next(&mut self) -> Option<T> {
180        self.pull()
181    }
182}
183
184// SAFETY: a `HostStream` owns its handle and is consumed on a single thread within
185// one client-streaming method call (the host never shares the handle concurrently).
186// The `Send` bound lets the macro wrap it in a `Stream<T>` (whose iterator is
187// `Send`) for the user method to consume.
188unsafe impl<T> Send for HostStream<T> {}
189
190impl<T> Drop for HostStream<T> {
191    fn drop(&mut self) {
192        // SAFETY: the handle is valid + owned; `drop_fn` is called exactly once.
193        unsafe { ((*self.handle).drop_fn)(self.handle) };
194    }
195}
196
197#[cfg(test)]
198mod host_stream_tests {
199    use super::*;
200
201    struct MockProducer {
202        items: Vec<u64>,
203        idx: usize,
204    }
205
206    unsafe extern "C" fn mock_next(
207        h: *mut FidiusStreamHandle,
208        buf: *mut u8,
209        cap: u32,
210        out_len: *mut u32,
211    ) -> i32 {
212        let p = &mut *((*h).state as *mut MockProducer);
213        if p.idx >= p.items.len() {
214            return crate::status::STATUS_STREAM_END;
215        }
216        let bytes = crate::wire::serialize(&p.items[p.idx]).unwrap();
217        if bytes.len() > cap as usize {
218            *out_len = bytes.len() as u32;
219            return crate::status::STATUS_BUFFER_TOO_SMALL;
220        }
221        core::ptr::copy_nonoverlapping(bytes.as_ptr(), buf, bytes.len());
222        *out_len = bytes.len() as u32;
223        p.idx += 1;
224        crate::status::STATUS_OK
225    }
226
227    unsafe extern "C" fn mock_drop(h: *mut FidiusStreamHandle) {
228        drop(Box::from_raw((*h).state as *mut MockProducer));
229        drop(Box::from_raw(h));
230    }
231
232    fn mock_handle(items: Vec<u64>) -> *mut FidiusStreamHandle {
233        let producer = Box::into_raw(Box::new(MockProducer { items, idx: 0 }));
234        Box::into_raw(Box::new(FidiusStreamHandle {
235            next: mock_next,
236            drop_fn: mock_drop,
237            state: producer as *mut c_void,
238        }))
239    }
240
241    #[test]
242    fn host_stream_consumes_all_items_then_drops_cleanly() {
243        let h = mock_handle(vec![10u64, 20, 30]);
244        // SAFETY: `h` is a freshly-built, exclusively-owned handle.
245        let consumer = unsafe { HostStream::<u64>::from_handle(h) };
246        let got: Vec<u64> = consumer.collect();
247        assert_eq!(got, vec![10, 20, 30]);
248        // Dropping `consumer` ran `mock_drop` (freed producer + handle) — under
249        // Miri/ASAN this would catch a leak or double-free.
250    }
251}