Skip to main content

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    /// Construct a **configured** plugin instance from a DYNAMICALLY loaded
106    /// cdylib — the dynamic-load analogue of [`Self::configure_in_process`]. A
107    /// [`LoadedPlugin`](crate::loader::LoadedPlugin) from
108    /// [`load_library`](crate::loader::load_library) / [`PluginHost::load`] is
109    /// constructed with `config` bound once (the plugin's `#[plugin_impl(Trait,
110    /// config = C)]` `configure` constructor receives it) instead of the
111    /// singleton [`Self::from_loaded`] builds. This lets a host load a
112    /// configured provider cdylib at runtime and bind N differently-configured
113    /// instances from the same library. The config crosses the boundary exactly
114    /// once, at construction.
115    ///
116    /// [`PluginHost::load`]: crate::PluginHost::load
117    pub fn configure_from_loaded<C: Serialize>(
118        plugin: crate::loader::LoadedPlugin,
119        config: &C,
120    ) -> Result<Self, LoadError> {
121        let cfg = fidius_core::wire::serialize(config)
122            .map_err(|e| LoadError::ConfigSerialization(e.to_string()))?;
123        Ok(Self {
124            backend: Backend::Cdylib(CdylibExecutor::from_loaded_with_config(plugin, &cfg)),
125        })
126    }
127
128    /// Look up a descriptor in the current process's inventory registry by
129    /// `plugin_name` (the Rust struct name passed to `#[plugin_impl]`).
130    pub fn find_in_process_descriptor(
131        plugin_name: &str,
132    ) -> Result<&'static PluginDescriptor, LoadError> {
133        CdylibExecutor::find_in_process_descriptor(plugin_name)
134    }
135
136    /// Create a `PluginHandle` backed by a loaded Python plugin. `info` is
137    /// built by the loader from the package manifest + interface descriptor.
138    /// Only available with the `python` feature.
139    #[cfg(feature = "python")]
140    pub fn from_python(py: fidius_python::PythonPluginHandle, info: PluginInfo) -> Self {
141        Self {
142            backend: Backend::Python(Pyo3Executor::new(py, info)),
143        }
144    }
145
146    /// Create a `PluginHandle` backed by a loaded WASM component. Only
147    /// available with the `wasm` feature.
148    #[cfg(feature = "wasm")]
149    pub fn from_wasm(executor: WasmComponentExecutor) -> Self {
150        Self {
151            backend: Backend::Wasm(executor),
152        }
153    }
154
155    /// Call a plugin method by vtable index.
156    ///
157    /// Serializes the input with the backend's native wire (cdylib → bincode;
158    /// Python/WASM → [`fidius_core::Value`]), dispatches, and decodes the
159    /// result into `O`. No built-in timeout — see the `fidius` crate docs.
160    pub fn call_method<I: Serialize, O: DeserializeOwned>(
161        &self,
162        index: usize,
163        input: &I,
164    ) -> Result<O, CallError> {
165        match &self.backend {
166            // cdylib: serialise the concrete type with bincode directly — byte
167            // for byte what the plugin's shim decodes (no `Value` hop).
168            Backend::Cdylib(e) => e.call_method(index, input),
169            // python: cross via the self-describing `Value` currency.
170            #[cfg(feature = "python")]
171            Backend::Python(e) => {
172                let args = fidius_core::to_value(input)
173                    .map_err(|err| CallError::Serialization(err.to_string()))?;
174                let out = ValueExecutor::call(e, index, args)?;
175                fidius_core::from_value(out)
176                    .map_err(|err| CallError::Deserialization(err.to_string()))
177            }
178            // wasm: same self-describing `Value` currency as python.
179            #[cfg(feature = "wasm")]
180            Backend::Wasm(e) => {
181                let args = fidius_core::to_value(input)
182                    .map_err(|err| CallError::Serialization(err.to_string()))?;
183                let out = ValueExecutor::call(e, index, args)?;
184                fidius_core::from_value(out)
185                    .map_err(|err| CallError::Deserialization(err.to_string()))
186            }
187        }
188    }
189
190    /// Start a server-streaming method call by vtable index (FIDIUS-I-0026).
191    ///
192    /// Returns a [`crate::stream::ChunkStream`] — a `futures::Stream` of
193    /// `Result<Value, _>` the caller pulls with `.next().await`. Backpressure and
194    /// cancellation are structural: a slow consumer parks the producer, and
195    /// dropping the stream tears the producer down. All three backends stream:
196    /// Python and WASM cross via the self-describing [`Value`] currency; cdylib
197    /// crosses items as concrete bincode of the item type `O` and decodes them
198    /// here (FIDIUS-T-0137).
199    ///
200    /// `O` is the stream's item type. Python/WASM ignore it (they're already
201    /// `Value`-native); cdylib uses it to `bincode::<O>`-decode each item.
202    #[cfg(feature = "streaming")]
203    pub async fn call_streaming<I: Serialize, O: DeserializeOwned + Serialize>(
204        &self,
205        index: usize,
206        input: &I,
207    ) -> Result<crate::stream::ChunkStream, CallError> {
208        match &self.backend {
209            // cdylib: concrete bincode of the args (no `Value` hop), then the
210            // iterator-handle streaming path (FIDIUS-I-0026 CS.1). Items also cross
211            // as concrete bincode, decoded by `cdylib_stream_decode::<O>`.
212            Backend::Cdylib(e) => {
213                let input_bytes = fidius_core::wire::serialize(input)
214                    .map_err(|err| CallError::Serialization(err.to_string()))?;
215                e.call_streaming_raw(index, &input_bytes, cdylib_stream_decode::<O>)
216            }
217            #[cfg(feature = "python")]
218            Backend::Python(e) => {
219                let args = fidius_core::to_value(input)
220                    .map_err(|err| CallError::Serialization(err.to_string()))?;
221                crate::stream::StreamExecutor::call_streaming(e, index, args).await
222            }
223            #[cfg(feature = "wasm")]
224            Backend::Wasm(e) => {
225                let args = fidius_core::to_value(input)
226                    .map_err(|err| CallError::Serialization(err.to_string()))?;
227                crate::stream::StreamExecutor::call_streaming(e, index, args).await
228            }
229        }
230    }
231
232    /// Start a **bidirectional** streaming call (FIDIUS-I-0032 / ADR-0010): the host
233    /// produces `items` (the plugin's `Stream<In>` argument) and consumes the plugin's
234    /// `Stream<Out>` return as the returned [`crate::stream::ChunkStream`]. Pulling the
235    /// output drives the plugin, which pulls the input on demand — the synchronous
236    /// lazy-pull composition. `args` are the non-stream arguments. `O` is the output
237    /// item type. Wired for cdylib; WASM/Python are BD.3/BD.4.
238    #[cfg(feature = "streaming")]
239    pub async fn call_bidi_streaming<I, A, O>(
240        &self,
241        index: usize,
242        items: impl IntoIterator<Item = I, IntoIter: Send + 'static>,
243        args: &A,
244    ) -> Result<crate::stream::ChunkStream, CallError>
245    where
246        I: Serialize + 'static,
247        A: Serialize,
248        O: DeserializeOwned + Serialize,
249    {
250        match &self.backend {
251            // Lazy producer — items are encoded only as the plugin pulls them (T-0172).
252            Backend::Cdylib(e) => {
253                let handle = crate::client_stream::host_producer_handle_typed(items.into_iter());
254                let arg_bytes = fidius_core::wire::serialize(args)
255                    .map_err(|err| CallError::Serialization(err.to_string()))?;
256                // SAFETY: `handle` is a freshly-built, exclusively-owned producer.
257                unsafe {
258                    e.call_bidi_streaming_raw(index, handle, &arg_bytes, cdylib_stream_decode::<O>)
259                }
260            }
261            #[cfg(feature = "python")]
262            Backend::Python(e) => {
263                // Python crosses via the self-describing `Value` currency, streamed lazily.
264                let producer = lazy_json_producer(items);
265                let arg_value = fidius_core::to_value(args)
266                    .map_err(|err| CallError::Serialization(err.to_string()))?;
267                e.call_bidi_streaming(index, producer, arg_value)
268            }
269            #[cfg(feature = "wasm")]
270            Backend::Wasm(e) => {
271                let producer = lazy_bincode_producer(items);
272                let arg_value = fidius_core::to_value(args)
273                    .map_err(|err| CallError::Serialization(err.to_string()))?;
274                e.call_bidi_streaming(index, producer, arg_value).await
275            }
276        }
277    }
278
279    /// Call a `#[wire(raw)]` method: raw bytes in, raw bytes out, no bincode.
280    pub fn call_method_raw(&self, index: usize, input: &[u8]) -> Result<Vec<u8>, CallError> {
281        match &self.backend {
282            Backend::Cdylib(e) => e.call_method_raw(index, input),
283            #[cfg(feature = "python")]
284            Backend::Python(e) => PluginExecutor::call_raw(e, index, input),
285            #[cfg(feature = "wasm")]
286            Backend::Wasm(e) => PluginExecutor::call_raw(e, index, input),
287        }
288    }
289
290    /// Client-streaming raw call (FIDIUS-I-0030 CS2.2): pass the host's producer
291    /// `handle` (built via [`crate::client_stream::host_producer_handle`]) and the
292    /// bincode of the non-stream args; returns the bincode of the method's result.
293    /// Wired for the cdylib backend; WASM/Python land in CS2.3/CS2.4. The typed
294    /// `call_client_streaming` wrapper is CS2.5.
295    ///
296    /// # Safety
297    /// `handle` must be a valid, exclusively-owned producer handle (e.g. from
298    /// [`crate::client_stream::host_producer_handle`]); it is consumed by the call.
299    #[cfg(feature = "streaming")]
300    pub unsafe fn call_client_streaming_raw(
301        &self,
302        index: usize,
303        handle: *mut fidius_core::stream_ffi::FidiusStreamHandle,
304        input: &[u8],
305    ) -> Result<Vec<u8>, CallError> {
306        match &self.backend {
307            // SAFETY: forwarded per this fn's contract.
308            Backend::Cdylib(e) => unsafe { e.call_client_streaming_raw(index, handle, input) },
309            #[cfg(feature = "python")]
310            Backend::Python(_) => Err(CallError::Backend {
311                runtime: "python".into(),
312                message: "client-streaming is not yet wired for Python (FIDIUS-I-0030 CS2.4)"
313                    .into(),
314            }),
315            #[cfg(feature = "wasm")]
316            Backend::Wasm(_) => Err(CallError::Backend {
317                runtime: "wasm".into(),
318                message: "use the typed `call_client_streaming` for the WASM backend".into(),
319            }),
320        }
321    }
322
323    /// Typed client-streaming (FIDIUS-I-0030): the host produces `items` (the
324    /// `Stream<T>` argument); the plugin pulls + consumes them and returns `O`.
325    /// `args` are the method's non-stream arguments (a tuple). Wired for cdylib
326    /// (in-process producer handle) and WASM (the `fidius:stream-pull` import);
327    /// Python is CS2.4. The safe wrapper over the per-backend mechanisms.
328    #[cfg(feature = "streaming")]
329    pub fn call_client_streaming<I, A, O>(
330        &self,
331        method: usize,
332        items: impl IntoIterator<Item = I, IntoIter: Send + 'static>,
333        args: &A,
334    ) -> Result<O, CallError>
335    where
336        I: Serialize + 'static,
337        A: Serialize,
338        O: DeserializeOwned,
339    {
340        match &self.backend {
341            // cdylib: a lazy producer handle — each item is bincode-encoded only as the
342            // plugin pulls it, so an unbounded input stays bounded in memory (T-0172).
343            Backend::Cdylib(e) => {
344                let handle = crate::client_stream::host_producer_handle_typed(items.into_iter());
345                let arg_bytes = fidius_core::wire::serialize(args)
346                    .map_err(|e| CallError::Serialization(e.to_string()))?;
347                // SAFETY: `handle` is a freshly-built, exclusively-owned producer.
348                let out = unsafe { e.call_client_streaming_raw(method, handle, &arg_bytes) }?;
349                fidius_core::wire::deserialize(&out)
350                    .map_err(|e| CallError::Deserialization(e.to_string()))
351            }
352            // WASM: same laziness — the boxed producer encodes on pull from the import.
353            #[cfg(feature = "wasm")]
354            Backend::Wasm(e) => {
355                let producer = lazy_bincode_producer(items);
356                let arg_value = fidius_core::to_value(args)
357                    .map_err(|err| CallError::Serialization(err.to_string()))?;
358                let out = e.call_client_streaming(method, producer, arg_value)?;
359                fidius_core::from_value(out)
360                    .map_err(|err| CallError::Deserialization(err.to_string()))
361            }
362            // Python crosses via the self-describing `Value` currency, streamed lazily
363            // (FIDIUS-T-0174) — each item is converted only as the Python iterator pulls it.
364            #[cfg(feature = "python")]
365            Backend::Python(e) => {
366                let producer = lazy_json_producer(items);
367                let arg_value = fidius_core::to_value(args)
368                    .map_err(|err| CallError::Serialization(err.to_string()))?;
369                let out = e.call_client_streaming(method, producer, arg_value)?;
370                fidius_core::from_value(out)
371                    .map_err(|err| CallError::Deserialization(err.to_string()))
372            }
373        }
374    }
375
376    /// Check if an optional method is supported (capability bit set).
377    /// Returns `false` for `bit >= 64` and for backends without capabilities.
378    pub fn has_capability(&self, bit: u32) -> bool {
379        if bit >= 64 {
380            return false;
381        }
382        self.info().capabilities & (1u64 << bit) != 0
383    }
384
385    /// Access the plugin's owned metadata.
386    pub fn info(&self) -> &PluginInfo {
387        match &self.backend {
388            Backend::Cdylib(e) => e.info(),
389            #[cfg(feature = "python")]
390            Backend::Python(e) => PluginExecutor::info(e),
391            #[cfg(feature = "wasm")]
392            Backend::Wasm(e) => PluginExecutor::info(e),
393        }
394    }
395
396    /// Static `#[method_meta(...)]` key/value metadata for the given method,
397    /// in declaration order. Empty for out-of-range ids, for interfaces that
398    /// declared none, and for backends without descriptor metadata.
399    pub fn method_metadata(&self, method_id: u32) -> Vec<(&str, &str)> {
400        match &self.backend {
401            Backend::Cdylib(e) => e.method_metadata(method_id),
402            // Python/WASM plugins carry no descriptor-level method metadata.
403            #[cfg(feature = "python")]
404            Backend::Python(_) => Vec::new(),
405            #[cfg(feature = "wasm")]
406            Backend::Wasm(_) => Vec::new(),
407        }
408    }
409
410    /// Static `#[trait_meta(...)]` key/value metadata declared on the trait.
411    /// Empty when none was declared or for backends without descriptor metadata.
412    pub fn trait_metadata(&self) -> Vec<(&str, &str)> {
413        match &self.backend {
414            Backend::Cdylib(e) => e.trait_metadata(),
415            #[cfg(feature = "python")]
416            Backend::Python(_) => Vec::new(),
417            #[cfg(feature = "wasm")]
418            Backend::Wasm(_) => Vec::new(),
419        }
420    }
421}
422
423/// Per-item decoder for the cdylib streaming fast path (FIDIUS-T-0137): each item
424/// crosses as concrete `bincode(O)` (byte-identical to the unary cdylib wire), so
425/// we `wire::deserialize::<O>` then lift to a `Value`. This is the `decode_item`
426/// fn pointer the typed caller hands to [`CdylibExecutor::call_streaming_raw`] —
427/// `O` is monomorphised in by `call_streaming::<_, O>`.
428#[cfg(feature = "streaming")]
429fn cdylib_stream_decode<O: DeserializeOwned + Serialize>(
430    bytes: &[u8],
431) -> Result<fidius_core::Value, CallError> {
432    let item: O = fidius_core::wire::deserialize(bytes)
433        .map_err(|e| CallError::Deserialization(e.to_string()))?;
434    fidius_core::to_value(&item).map_err(|e| CallError::Serialization(e.to_string()))
435}
436
437/// A lazy, boxed bincode producer for the WASM client/bidi streaming input path: each
438/// item is bincode-encoded only when the guest's `fidius:stream-pull` import pulls it
439/// (FIDIUS-T-0172), so an unbounded input stays bounded in host memory. An item that fails
440/// to encode is skipped (bincode of a `Serialize` type is effectively infallible, and a
441/// panic must not cross the host→guest call).
442#[cfg(all(feature = "streaming", feature = "wasm"))]
443fn lazy_bincode_producer<I: Serialize + 'static>(
444    items: impl IntoIterator<Item = I, IntoIter: Send + 'static>,
445) -> Box<dyn Iterator<Item = Vec<u8>> + Send> {
446    Box::new(
447        items
448            .into_iter()
449            .filter_map(|i| fidius_core::wire::serialize(&i).ok()),
450    )
451}
452
453/// A lazy, boxed producer of `Value`-shaped JSON for the Python client/bidi streaming
454/// input path (FIDIUS-T-0174): each item is converted (`I` → `Value` → `serde_json`) only
455/// as the Python iterator pulls it, so an unbounded input stays bounded in host memory. An
456/// item that fails to convert is skipped (effectively infallible for real types; a panic
457/// must not cross into the interpreter).
458#[cfg(all(feature = "streaming", feature = "python"))]
459fn lazy_json_producer<I: Serialize + 'static>(
460    items: impl IntoIterator<Item = I, IntoIter: Send + 'static>,
461) -> Box<dyn Iterator<Item = serde_json::Value> + Send> {
462    Box::new(items.into_iter().filter_map(|i| {
463        fidius_core::to_value(&i)
464            .ok()
465            .and_then(|v| serde_json::to_value(v).ok())
466    }))
467}