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}