fidius_host/handle.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//! `PluginHandle` — the unified, caller-facing proxy over a loaded plugin.
16//!
17//! A `PluginHandle` is backend-agnostic: callers use the same
18//! `call_method` / `call_method_raw` API whether the plugin is a cdylib, a
19//! Python package, or (Phase 2) a WASM component. The backend lives in the
20//! private [`Backend`] enum.
21//!
22//! ## Why an enum backend (FIDIUS-I-0021)
23//!
24//! The backends don't share a typed wire: cdylib decodes concrete-type
25//! **bincode** (not reconstructable from an erased value) while Python/WASM
26//! consume a self-describing [`fidius_core::Value`]. An enum (rather than
27//! `Box<dyn PluginExecutor>`) lets the generic `call_method<I, O>` branch with
28//! the concrete `I`/`O` in scope and serialise with each backend's native
29//! currency — so the **cdylib path stays byte-identical** to before this
30//! refactor (`bincode(input)` straight to the FFI; `Value` is never involved).
31
32use serde::de::DeserializeOwned;
33use serde::Serialize;
34
35use fidius_core::descriptor::PluginDescriptor;
36
37use crate::error::{CallError, LoadError};
38use crate::executor::cdylib::CdylibExecutor;
39#[cfg(feature = "python")]
40use crate::executor::python::Pyo3Executor;
41#[cfg(feature = "wasm")]
42use crate::executor::wasm::WasmComponentExecutor;
43#[cfg(any(feature = "python", feature = "wasm"))]
44use crate::executor::{PluginExecutor, ValueExecutor};
45use crate::types::PluginInfo;
46
47/// The execution backend behind a [`PluginHandle`].
48///
49/// One variant per runtime. The WASM variant lands in Phase 2.
50enum Backend {
51 Cdylib(CdylibExecutor),
52 /// `.py` package via `fidius-python`'s embedded interpreter. Only present
53 /// when the `python` feature is enabled.
54 #[cfg(feature = "python")]
55 Python(Pyo3Executor),
56 /// `.wasm` component via wasmtime. Only present when the `wasm` feature is
57 /// enabled.
58 #[cfg(feature = "wasm")]
59 Wasm(WasmComponentExecutor),
60}
61
62/// A handle to a loaded plugin, ready for calling methods.
63///
64/// Holds the active execution backend. `call_method()` handles serialization,
65/// dispatch, and cleanup; concurrent calls from multiple threads are safe as
66/// long as the underlying plugin is thread-safe (the cdylib macro enforces
67/// `&self`-only methods; the Python backend serialises through the GIL).
68pub struct PluginHandle {
69 backend: Backend,
70}
71
72impl PluginHandle {
73 /// Create a `PluginHandle` from a freshly loaded cdylib plugin.
74 pub fn from_loaded(plugin: crate::loader::LoadedPlugin) -> Self {
75 Self {
76 backend: Backend::Cdylib(CdylibExecutor::from_loaded(plugin)),
77 }
78 }
79
80 /// Create a `PluginHandle` from a descriptor already registered in the
81 /// current process's inventory (a `#[plugin_impl]` linked as a normal
82 /// rlib). No dylib is loaded. Used by `Client::in_process(plugin_name)`.
83 pub fn from_descriptor(desc: &'static PluginDescriptor) -> Result<Self, LoadError> {
84 Ok(Self {
85 backend: Backend::Cdylib(CdylibExecutor::from_descriptor(desc)?),
86 })
87 }
88
89 /// Construct a **configured** in-process plugin instance (FIDIUS-A-0006 /
90 /// CI.2): serialize `config` and bind it once at construction. The plugin's
91 /// `#[plugin_impl(Trait, config = C)]` `configure` constructor receives it;
92 /// methods then close over it without re-passing. The config crosses the
93 /// boundary exactly once, and N differently-configured instances can coexist.
94 pub fn configure_in_process<C: Serialize>(
95 desc: &'static PluginDescriptor,
96 config: &C,
97 ) -> Result<Self, LoadError> {
98 let cfg = fidius_core::wire::serialize(config)
99 .map_err(|e| LoadError::ConfigSerialization(e.to_string()))?;
100 Ok(Self {
101 backend: Backend::Cdylib(CdylibExecutor::from_descriptor_with_config(desc, &cfg)?),
102 })
103 }
104
105 /// Look up a descriptor in the current process's inventory registry by
106 /// `plugin_name` (the Rust struct name passed to `#[plugin_impl]`).
107 pub fn find_in_process_descriptor(
108 plugin_name: &str,
109 ) -> Result<&'static PluginDescriptor, LoadError> {
110 CdylibExecutor::find_in_process_descriptor(plugin_name)
111 }
112
113 /// Create a `PluginHandle` backed by a loaded Python plugin. `info` is
114 /// built by the loader from the package manifest + interface descriptor.
115 /// Only available with the `python` feature.
116 #[cfg(feature = "python")]
117 pub fn from_python(py: fidius_python::PythonPluginHandle, info: PluginInfo) -> Self {
118 Self {
119 backend: Backend::Python(Pyo3Executor::new(py, info)),
120 }
121 }
122
123 /// Create a `PluginHandle` backed by a loaded WASM component. Only
124 /// available with the `wasm` feature.
125 #[cfg(feature = "wasm")]
126 pub fn from_wasm(executor: WasmComponentExecutor) -> Self {
127 Self {
128 backend: Backend::Wasm(executor),
129 }
130 }
131
132 /// Call a plugin method by vtable index.
133 ///
134 /// Serializes the input with the backend's native wire (cdylib → bincode;
135 /// Python/WASM → [`fidius_core::Value`]), dispatches, and decodes the
136 /// result into `O`. No built-in timeout — see the `fidius` crate docs.
137 pub fn call_method<I: Serialize, O: DeserializeOwned>(
138 &self,
139 index: usize,
140 input: &I,
141 ) -> Result<O, CallError> {
142 match &self.backend {
143 // cdylib: serialise the concrete type with bincode directly — byte
144 // for byte what the plugin's shim decodes (no `Value` hop).
145 Backend::Cdylib(e) => e.call_method(index, input),
146 // python: cross via the self-describing `Value` currency.
147 #[cfg(feature = "python")]
148 Backend::Python(e) => {
149 let args = fidius_core::to_value(input)
150 .map_err(|err| CallError::Serialization(err.to_string()))?;
151 let out = ValueExecutor::call(e, index, args)?;
152 fidius_core::from_value(out)
153 .map_err(|err| CallError::Deserialization(err.to_string()))
154 }
155 // wasm: same self-describing `Value` currency as python.
156 #[cfg(feature = "wasm")]
157 Backend::Wasm(e) => {
158 let args = fidius_core::to_value(input)
159 .map_err(|err| CallError::Serialization(err.to_string()))?;
160 let out = ValueExecutor::call(e, index, args)?;
161 fidius_core::from_value(out)
162 .map_err(|err| CallError::Deserialization(err.to_string()))
163 }
164 }
165 }
166
167 /// Start a server-streaming method call by vtable index (FIDIUS-I-0026).
168 ///
169 /// Returns a [`crate::stream::ChunkStream`] — a `futures::Stream` of
170 /// `Result<Value, _>` the caller pulls with `.next().await`. Backpressure and
171 /// cancellation are structural: a slow consumer parks the producer, and
172 /// dropping the stream tears the producer down. All three backends stream:
173 /// Python and WASM cross via the self-describing [`Value`] currency; cdylib
174 /// crosses items as concrete bincode of the item type `O` and decodes them
175 /// here (FIDIUS-T-0137).
176 ///
177 /// `O` is the stream's item type. Python/WASM ignore it (they're already
178 /// `Value`-native); cdylib uses it to `bincode::<O>`-decode each item.
179 #[cfg(feature = "streaming")]
180 pub async fn call_streaming<I: Serialize, O: DeserializeOwned + Serialize>(
181 &self,
182 index: usize,
183 input: &I,
184 ) -> Result<crate::stream::ChunkStream, CallError> {
185 match &self.backend {
186 // cdylib: concrete bincode of the args (no `Value` hop), then the
187 // iterator-handle streaming path (FIDIUS-I-0026 CS.1). Items also cross
188 // as concrete bincode, decoded by `cdylib_stream_decode::<O>`.
189 Backend::Cdylib(e) => {
190 let input_bytes = fidius_core::wire::serialize(input)
191 .map_err(|err| CallError::Serialization(err.to_string()))?;
192 e.call_streaming_raw(index, &input_bytes, cdylib_stream_decode::<O>)
193 }
194 #[cfg(feature = "python")]
195 Backend::Python(e) => {
196 let args = fidius_core::to_value(input)
197 .map_err(|err| CallError::Serialization(err.to_string()))?;
198 crate::stream::StreamExecutor::call_streaming(e, index, args).await
199 }
200 #[cfg(feature = "wasm")]
201 Backend::Wasm(e) => {
202 let args = fidius_core::to_value(input)
203 .map_err(|err| CallError::Serialization(err.to_string()))?;
204 crate::stream::StreamExecutor::call_streaming(e, index, args).await
205 }
206 }
207 }
208
209 /// Start a **bidirectional** streaming call (FIDIUS-I-0032 / ADR-0010): the host
210 /// produces `items` (the plugin's `Stream<In>` argument) and consumes the plugin's
211 /// `Stream<Out>` return as the returned [`crate::stream::ChunkStream`]. Pulling the
212 /// output drives the plugin, which pulls the input on demand — the synchronous
213 /// lazy-pull composition. `args` are the non-stream arguments. `O` is the output
214 /// item type. Wired for cdylib; WASM/Python are BD.3/BD.4.
215 #[cfg(feature = "streaming")]
216 pub async fn call_bidi_streaming<I, A, O>(
217 &self,
218 index: usize,
219 items: impl IntoIterator<Item = I, IntoIter: Send + 'static>,
220 args: &A,
221 ) -> Result<crate::stream::ChunkStream, CallError>
222 where
223 I: Serialize + 'static,
224 A: Serialize,
225 O: DeserializeOwned + Serialize,
226 {
227 match &self.backend {
228 // Lazy producer — items are encoded only as the plugin pulls them (T-0172).
229 Backend::Cdylib(e) => {
230 let handle = crate::client_stream::host_producer_handle_typed(items.into_iter());
231 let arg_bytes = fidius_core::wire::serialize(args)
232 .map_err(|err| CallError::Serialization(err.to_string()))?;
233 // SAFETY: `handle` is a freshly-built, exclusively-owned producer.
234 unsafe {
235 e.call_bidi_streaming_raw(index, handle, &arg_bytes, cdylib_stream_decode::<O>)
236 }
237 }
238 #[cfg(feature = "python")]
239 Backend::Python(e) => {
240 // Python crosses via the self-describing `Value` currency, streamed lazily.
241 let producer = lazy_json_producer(items);
242 let arg_value = fidius_core::to_value(args)
243 .map_err(|err| CallError::Serialization(err.to_string()))?;
244 e.call_bidi_streaming(index, producer, arg_value)
245 }
246 #[cfg(feature = "wasm")]
247 Backend::Wasm(e) => {
248 let producer = lazy_bincode_producer(items);
249 let arg_value = fidius_core::to_value(args)
250 .map_err(|err| CallError::Serialization(err.to_string()))?;
251 e.call_bidi_streaming(index, producer, arg_value).await
252 }
253 }
254 }
255
256 /// Call a `#[wire(raw)]` method: raw bytes in, raw bytes out, no bincode.
257 pub fn call_method_raw(&self, index: usize, input: &[u8]) -> Result<Vec<u8>, CallError> {
258 match &self.backend {
259 Backend::Cdylib(e) => e.call_method_raw(index, input),
260 #[cfg(feature = "python")]
261 Backend::Python(e) => PluginExecutor::call_raw(e, index, input),
262 #[cfg(feature = "wasm")]
263 Backend::Wasm(e) => PluginExecutor::call_raw(e, index, input),
264 }
265 }
266
267 /// Client-streaming raw call (FIDIUS-I-0030 CS2.2): pass the host's producer
268 /// `handle` (built via [`crate::client_stream::host_producer_handle`]) and the
269 /// bincode of the non-stream args; returns the bincode of the method's result.
270 /// Wired for the cdylib backend; WASM/Python land in CS2.3/CS2.4. The typed
271 /// `call_client_streaming` wrapper is CS2.5.
272 ///
273 /// # Safety
274 /// `handle` must be a valid, exclusively-owned producer handle (e.g. from
275 /// [`crate::client_stream::host_producer_handle`]); it is consumed by the call.
276 #[cfg(feature = "streaming")]
277 pub unsafe fn call_client_streaming_raw(
278 &self,
279 index: usize,
280 handle: *mut fidius_core::stream_ffi::FidiusStreamHandle,
281 input: &[u8],
282 ) -> Result<Vec<u8>, CallError> {
283 match &self.backend {
284 // SAFETY: forwarded per this fn's contract.
285 Backend::Cdylib(e) => unsafe { e.call_client_streaming_raw(index, handle, input) },
286 #[cfg(feature = "python")]
287 Backend::Python(_) => Err(CallError::Backend {
288 runtime: "python".into(),
289 message: "client-streaming is not yet wired for Python (FIDIUS-I-0030 CS2.4)"
290 .into(),
291 }),
292 #[cfg(feature = "wasm")]
293 Backend::Wasm(_) => Err(CallError::Backend {
294 runtime: "wasm".into(),
295 message: "use the typed `call_client_streaming` for the WASM backend".into(),
296 }),
297 }
298 }
299
300 /// Typed client-streaming (FIDIUS-I-0030): the host produces `items` (the
301 /// `Stream<T>` argument); the plugin pulls + consumes them and returns `O`.
302 /// `args` are the method's non-stream arguments (a tuple). Wired for cdylib
303 /// (in-process producer handle) and WASM (the `fidius:stream-pull` import);
304 /// Python is CS2.4. The safe wrapper over the per-backend mechanisms.
305 #[cfg(feature = "streaming")]
306 pub fn call_client_streaming<I, A, O>(
307 &self,
308 method: usize,
309 items: impl IntoIterator<Item = I, IntoIter: Send + 'static>,
310 args: &A,
311 ) -> Result<O, CallError>
312 where
313 I: Serialize + 'static,
314 A: Serialize,
315 O: DeserializeOwned,
316 {
317 match &self.backend {
318 // cdylib: a lazy producer handle — each item is bincode-encoded only as the
319 // plugin pulls it, so an unbounded input stays bounded in memory (T-0172).
320 Backend::Cdylib(e) => {
321 let handle = crate::client_stream::host_producer_handle_typed(items.into_iter());
322 let arg_bytes = fidius_core::wire::serialize(args)
323 .map_err(|e| CallError::Serialization(e.to_string()))?;
324 // SAFETY: `handle` is a freshly-built, exclusively-owned producer.
325 let out = unsafe { e.call_client_streaming_raw(method, handle, &arg_bytes) }?;
326 fidius_core::wire::deserialize(&out)
327 .map_err(|e| CallError::Deserialization(e.to_string()))
328 }
329 // WASM: same laziness — the boxed producer encodes on pull from the import.
330 #[cfg(feature = "wasm")]
331 Backend::Wasm(e) => {
332 let producer = lazy_bincode_producer(items);
333 let arg_value = fidius_core::to_value(args)
334 .map_err(|err| CallError::Serialization(err.to_string()))?;
335 let out = e.call_client_streaming(method, producer, arg_value)?;
336 fidius_core::from_value(out)
337 .map_err(|err| CallError::Deserialization(err.to_string()))
338 }
339 // Python crosses via the self-describing `Value` currency, streamed lazily
340 // (FIDIUS-T-0174) — each item is converted only as the Python iterator pulls it.
341 #[cfg(feature = "python")]
342 Backend::Python(e) => {
343 let producer = lazy_json_producer(items);
344 let arg_value = fidius_core::to_value(args)
345 .map_err(|err| CallError::Serialization(err.to_string()))?;
346 let out = e.call_client_streaming(method, producer, arg_value)?;
347 fidius_core::from_value(out)
348 .map_err(|err| CallError::Deserialization(err.to_string()))
349 }
350 }
351 }
352
353 /// Check if an optional method is supported (capability bit set).
354 /// Returns `false` for `bit >= 64` and for backends without capabilities.
355 pub fn has_capability(&self, bit: u32) -> bool {
356 if bit >= 64 {
357 return false;
358 }
359 self.info().capabilities & (1u64 << bit) != 0
360 }
361
362 /// Access the plugin's owned metadata.
363 pub fn info(&self) -> &PluginInfo {
364 match &self.backend {
365 Backend::Cdylib(e) => e.info(),
366 #[cfg(feature = "python")]
367 Backend::Python(e) => PluginExecutor::info(e),
368 #[cfg(feature = "wasm")]
369 Backend::Wasm(e) => PluginExecutor::info(e),
370 }
371 }
372
373 /// Static `#[method_meta(...)]` key/value metadata for the given method,
374 /// in declaration order. Empty for out-of-range ids, for interfaces that
375 /// declared none, and for backends without descriptor metadata.
376 pub fn method_metadata(&self, method_id: u32) -> Vec<(&str, &str)> {
377 match &self.backend {
378 Backend::Cdylib(e) => e.method_metadata(method_id),
379 // Python/WASM plugins carry no descriptor-level method metadata.
380 #[cfg(feature = "python")]
381 Backend::Python(_) => Vec::new(),
382 #[cfg(feature = "wasm")]
383 Backend::Wasm(_) => Vec::new(),
384 }
385 }
386
387 /// Static `#[trait_meta(...)]` key/value metadata declared on the trait.
388 /// Empty when none was declared or for backends without descriptor metadata.
389 pub fn trait_metadata(&self) -> Vec<(&str, &str)> {
390 match &self.backend {
391 Backend::Cdylib(e) => e.trait_metadata(),
392 #[cfg(feature = "python")]
393 Backend::Python(_) => Vec::new(),
394 #[cfg(feature = "wasm")]
395 Backend::Wasm(_) => Vec::new(),
396 }
397 }
398}
399
400/// Per-item decoder for the cdylib streaming fast path (FIDIUS-T-0137): each item
401/// crosses as concrete `bincode(O)` (byte-identical to the unary cdylib wire), so
402/// we `wire::deserialize::<O>` then lift to a `Value`. This is the `decode_item`
403/// fn pointer the typed caller hands to [`CdylibExecutor::call_streaming_raw`] —
404/// `O` is monomorphised in by `call_streaming::<_, O>`.
405#[cfg(feature = "streaming")]
406fn cdylib_stream_decode<O: DeserializeOwned + Serialize>(
407 bytes: &[u8],
408) -> Result<fidius_core::Value, CallError> {
409 let item: O = fidius_core::wire::deserialize(bytes)
410 .map_err(|e| CallError::Deserialization(e.to_string()))?;
411 fidius_core::to_value(&item).map_err(|e| CallError::Serialization(e.to_string()))
412}
413
414/// A lazy, boxed bincode producer for the WASM client/bidi streaming input path: each
415/// item is bincode-encoded only when the guest's `fidius:stream-pull` import pulls it
416/// (FIDIUS-T-0172), so an unbounded input stays bounded in host memory. An item that fails
417/// to encode is skipped (bincode of a `Serialize` type is effectively infallible, and a
418/// panic must not cross the host→guest call).
419#[cfg(all(feature = "streaming", feature = "wasm"))]
420fn lazy_bincode_producer<I: Serialize + 'static>(
421 items: impl IntoIterator<Item = I, IntoIter: Send + 'static>,
422) -> Box<dyn Iterator<Item = Vec<u8>> + Send> {
423 Box::new(
424 items
425 .into_iter()
426 .filter_map(|i| fidius_core::wire::serialize(&i).ok()),
427 )
428}
429
430/// A lazy, boxed producer of `Value`-shaped JSON for the Python client/bidi streaming
431/// input path (FIDIUS-T-0174): each item is converted (`I` → `Value` → `serde_json`) only
432/// as the Python iterator pulls it, so an unbounded input stays bounded in host memory. An
433/// item that fails to convert is skipped (effectively infallible for real types; a panic
434/// must not cross into the interpreter).
435#[cfg(all(feature = "streaming", feature = "python"))]
436fn lazy_json_producer<I: Serialize + 'static>(
437 items: impl IntoIterator<Item = I, IntoIter: Send + 'static>,
438) -> Box<dyn Iterator<Item = serde_json::Value> + Send> {
439 Box::new(items.into_iter().filter_map(|i| {
440 fidius_core::to_value(&i)
441 .ok()
442 .and_then(|v| serde_json::to_value(v).ok())
443 }))
444}