Skip to main content

fidius_host/executor/
cdylib.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//! `CdylibExecutor` — the cdylib execution backend: vtable/FFI dispatch with
16//! the bincode wire format.
17//!
18//! This is the original `PluginHandle` dispatch logic, moved behind the
19//! [`crate::executor::PluginExecutor`] seam (FIDIUS-I-0021). It keeps its own
20//! generic `call_method<I, O>` so the cdylib typed path serializes the concrete
21//! type with bincode **directly** — `Value` is never involved, so the bytes the
22//! plugin decodes are byte-identical to pre-refactor. The public-facing
23//! [`crate::handle::PluginHandle`] wraps this in an enum alongside the Python
24//! (and future WASM) backends.
25
26use std::ffi::c_void;
27use std::sync::Arc;
28
29use libloading::Library;
30use serde::de::DeserializeOwned;
31use serde::Serialize;
32
33use fidius_core::descriptor::{BufferStrategyKind, PluginDescriptor};
34use fidius_core::status::*;
35use fidius_core::wire;
36use fidius_core::PluginError;
37
38use crate::arena::{acquire_arena, grow_arena, release_arena, DEFAULT_ARENA_CAPACITY};
39use crate::error::{CallError, LoadError};
40use crate::executor::PluginExecutor;
41use crate::types::PluginInfo;
42
43/// Type alias for the PluginAllocated FFI function pointer signature.
44/// FIDIUS-A-0006: every method takes the instance pointer first.
45type FfiFn = unsafe extern "C" fn(*mut c_void, *const u8, u32, *mut *mut u8, *mut u32) -> i32;
46
47/// Type alias for the Arena FFI function pointer signature.
48type ArenaFn =
49    unsafe extern "C" fn(*mut c_void, *const u8, u32, *mut u8, u32, *mut u32, *mut u32) -> i32;
50
51/// Construct the plugin instance via the descriptor's `construct` (FIDIUS-A-0006).
52/// Empty config bytes = the zero-config / singleton case (CI.1; typed config is CI.2).
53///
54/// # Safety
55/// `descriptor` must point to a valid `PluginDescriptor`.
56unsafe fn construct_instance(descriptor: *const PluginDescriptor, cfg: &[u8]) -> *mut c_void {
57    match (*descriptor).construct {
58        Some(ctor) => ctor(cfg.as_ptr(), cfg.len() as u32),
59        None => std::ptr::null_mut(),
60    }
61}
62
63/// A handle to a loaded plugin, ready for calling methods.
64///
65/// Holds an `Arc<Library>` to keep the dylib loaded as long as any handle exists.
66/// Call methods via `call_method()` which handles serialization, FFI, and cleanup.
67///
68/// `CdylibExecutor` is `Send + Sync`. Plugin methods take `&self` (enforced by
69/// the macro), so concurrent calls from multiple threads are safe as long as
70/// the plugin implementation is thread-safe internally.
71pub struct CdylibExecutor {
72    /// Keeps the library alive for dylib-loaded plugins. `None` for in-process
73    /// handles built via [`CdylibExecutor::from_descriptor`] — in-process plugins
74    /// live in the current binary's address space and don't need Arc-tracking.
75    _library: Option<Arc<Library>>,
76    /// Pointer to the `#[repr(C)]` vtable struct in the loaded library.
77    vtable: *const c_void,
78    /// Pointer to the full descriptor in library memory. Used by metadata
79    /// accessors to read `method_metadata` / `trait_metadata`. Valid for the
80    /// handle's lifetime via `_library` Arc (or forever for in-process).
81    descriptor: *const PluginDescriptor,
82    /// Free function for plugin-allocated output buffers.
83    free_buffer: Option<unsafe extern "C" fn(*mut u8, usize)>,
84    /// Capability bitfield for optional method support.
85    capabilities: u64,
86    /// Total number of methods in the vtable.
87    method_count: u32,
88    /// Owned plugin metadata.
89    info: PluginInfo,
90    /// The plugin instance this handle owns (FIDIUS-A-0006), returned by the
91    /// descriptor's `construct` and passed to every vtable method. Freed via
92    /// `destroy` on drop. Null only for a malformed/legacy descriptor.
93    instance: *mut c_void,
94    /// Destructor for `instance` (from the descriptor).
95    destroy: Option<unsafe extern "C" fn(*mut c_void)>,
96}
97
98// SAFETY: CdylibExecutor is Send + Sync because:
99// - vtable and free_buffer are function pointers to static code in the loaded library
100// - Arc<Library> is Send + Sync and ensures the library stays loaded
101// - All access through call_method is read-only (no mutation of handle state)
102//
103// Plugin implementations must be thread-safe (&self methods, no &mut self)
104// if the CdylibExecutor is shared across threads. This is enforced at compile
105// time by the #[plugin_interface] macro which rejects &mut self methods.
106unsafe impl Send for CdylibExecutor {}
107unsafe impl Sync for CdylibExecutor {}
108
109impl Drop for CdylibExecutor {
110    fn drop(&mut self) {
111        // Release the plugin instance this handle owns (FIDIUS-A-0006).
112        if let Some(destroy) = self.destroy {
113            if !self.instance.is_null() {
114                unsafe { destroy(self.instance) };
115            }
116        }
117    }
118}
119
120impl CdylibExecutor {
121    /// Create a new CdylibExecutor. Crate-private — use `from_loaded()` instead.
122    #[allow(dead_code)]
123    pub(crate) fn new(
124        library: Arc<Library>,
125        vtable: *const c_void,
126        descriptor: *const PluginDescriptor,
127        free_buffer: Option<unsafe extern "C" fn(*mut u8, usize)>,
128        capabilities: u64,
129        method_count: u32,
130        info: PluginInfo,
131    ) -> Self {
132        let instance = unsafe { construct_instance(descriptor, &[]) };
133        let destroy = unsafe { (*descriptor).destroy };
134        Self {
135            _library: Some(library),
136            vtable,
137            descriptor,
138            free_buffer,
139            capabilities,
140            method_count,
141            info,
142            instance,
143            destroy,
144        }
145    }
146
147    /// Create a CdylibExecutor from a LoadedPlugin.
148    pub fn from_loaded(plugin: crate::loader::LoadedPlugin) -> Self {
149        Self::from_loaded_with_config(plugin, &[])
150    }
151
152    /// Like [`Self::from_loaded`] but binds serialized `cfg` config bytes at
153    /// construction — the DYNAMIC *configured* path (FIDIUS-A-0006 / CI.2). A
154    /// `#[plugin_impl(Trait, config = C)]` loaded from a dylib whose `configure`
155    /// constructor receives the bound config once; methods then close over it
156    /// without re-passing. `cfg` is bincode of the plugin's config type; empty
157    /// (`&[]`) reproduces [`Self::from_loaded`]'s singleton construction. This is
158    /// the dynamic-load analogue of [`Self::from_descriptor_with_config`].
159    pub fn from_loaded_with_config(plugin: crate::loader::LoadedPlugin, cfg: &[u8]) -> Self {
160        let instance = unsafe { construct_instance(plugin.descriptor, cfg) };
161        let destroy = unsafe { (*plugin.descriptor).destroy };
162        Self {
163            _library: Some(plugin.library),
164            vtable: plugin.vtable,
165            descriptor: plugin.descriptor,
166            free_buffer: plugin.free_buffer,
167            capabilities: plugin.info.capabilities,
168            method_count: plugin.method_count,
169            info: plugin.info,
170            instance,
171            destroy,
172        }
173    }
174
175    /// Create a CdylibExecutor from a plugin descriptor already registered in
176    /// the current process's inventory (via a `#[plugin_impl]` linked into
177    /// the current binary as a normal rlib). No dylib is loaded — the
178    /// descriptor's vtable points at code in the current binary.
179    ///
180    /// Used by the generated `Client::in_process(plugin_name)` constructor.
181    /// Host applications normally use [`CdylibExecutor::from_loaded`] instead.
182    pub fn from_descriptor(desc: &'static PluginDescriptor) -> Result<Self, LoadError> {
183        Self::from_descriptor_with_config(desc, &[])
184    }
185
186    /// Like [`Self::from_descriptor`] but constructs the instance from serialized
187    /// config bytes (FIDIUS-A-0006 / CI.2) — the in-process *configured* path.
188    /// `cfg` is bincode of the plugin's config type (empty = the singleton).
189    pub fn from_descriptor_with_config(
190        desc: &'static PluginDescriptor,
191        cfg: &[u8],
192    ) -> Result<Self, LoadError> {
193        let info = PluginInfo {
194            name: unsafe { desc.plugin_name_str() }.to_string(),
195            interface_name: unsafe { desc.interface_name_str() }.to_string(),
196            interface_hash: desc.interface_hash,
197            interface_version: desc.interface_version,
198            capabilities: desc.capabilities,
199            buffer_strategy: desc
200                .buffer_strategy_kind()
201                .map_err(|v| LoadError::UnknownBufferStrategy { value: v })?,
202            runtime: crate::types::PluginRuntimeKind::Cdylib,
203        };
204        let descriptor = desc as *const PluginDescriptor;
205        let instance = unsafe { construct_instance(descriptor, cfg) };
206        Ok(Self {
207            _library: None,
208            vtable: desc.vtable,
209            descriptor,
210            free_buffer: desc.free_buffer,
211            capabilities: desc.capabilities,
212            method_count: desc.method_count,
213            info,
214            instance,
215            destroy: desc.destroy,
216        })
217    }
218
219    /// Look up a descriptor in the current process's inventory registry by
220    /// `plugin_name` (the Rust struct name that was passed to `#[plugin_impl]`).
221    /// Returns `LoadError::PluginNotFound` if no descriptor has that name.
222    ///
223    /// The returned reference has `'static` lifetime because descriptors
224    /// emitted by `#[plugin_impl]` live in the binary's `.rodata`.
225    pub fn find_in_process_descriptor(
226        plugin_name: &str,
227    ) -> Result<&'static PluginDescriptor, LoadError> {
228        let reg = fidius_core::registry::get_registry();
229        for i in 0..reg.plugin_count as usize {
230            let desc_ptr = unsafe { *reg.descriptors.add(i) };
231            let desc = unsafe { &*desc_ptr };
232            if unsafe { desc.plugin_name_str() } == plugin_name {
233                return Ok(desc);
234            }
235        }
236        Err(LoadError::PluginNotFound {
237            name: plugin_name.to_string(),
238        })
239    }
240
241    /// Call a plugin method by vtable index.
242    ///
243    /// Serializes the input, calls the FFI function pointer at the given index,
244    /// checks the status code, deserializes the output, and frees the plugin-allocated buffer.
245    ///
246    /// # Arguments
247    /// * `index` - The method index in the vtable (0-based, in declaration order)
248    /// * `input` - The input argument to serialize and pass to the plugin
249    ///
250    /// # No timeout
251    ///
252    /// This call runs synchronously on the calling thread and has no built-in
253    /// timeout or cancellation. A misbehaving plugin will block the caller
254    /// indefinitely. See the `fidius` crate top-level docs ("What fidius
255    /// does not provide") for the rationale and the recommended consumer-side
256    /// mitigation.
257    pub fn call_method<I: Serialize, O: DeserializeOwned>(
258        &self,
259        index: usize,
260        input: &I,
261    ) -> Result<O, CallError> {
262        // Bounds check: ensure index is within the vtable
263        if index >= self.method_count as usize {
264            return Err(CallError::InvalidMethodIndex {
265                index,
266                count: self.method_count,
267            });
268        }
269
270        let input_bytes =
271            wire::serialize(input).map_err(|e| CallError::Serialization(e.to_string()))?;
272
273        match self.info.buffer_strategy {
274            BufferStrategyKind::PluginAllocated => self.call_plugin_allocated(index, &input_bytes),
275            BufferStrategyKind::Arena => self.call_arena(index, &input_bytes),
276        }
277    }
278
279    /// Call a plugin method whose argument and successful return value are
280    /// raw bytes — bypassing bincode on both sides. Used by methods declared
281    /// with `#[wire(raw)]` on the interface trait.
282    ///
283    /// Errors and panic messages still use bincode (small typed payloads).
284    /// Returns the success bytes on `Ok`, or a `CallError::Plugin(_)` whose
285    /// inner `PluginError` was bincode-decoded from the plugin's error payload.
286    ///
287    /// Same no-timeout caveat as [`Self::call_method`].
288    pub fn call_method_raw(&self, index: usize, input: &[u8]) -> Result<Vec<u8>, CallError> {
289        if index >= self.method_count as usize {
290            return Err(CallError::InvalidMethodIndex {
291                index,
292                count: self.method_count,
293            });
294        }
295        match self.info.buffer_strategy {
296            BufferStrategyKind::PluginAllocated => self.call_plugin_allocated_raw(index, input),
297            BufferStrategyKind::Arena => self.call_arena_raw(index, input),
298        }
299    }
300
301    /// PluginAllocated path: plugin allocates an output buffer via
302    /// `Box::into_raw(Box<[u8]>)`, host deserializes and calls free_buffer.
303    fn call_plugin_allocated<O: DeserializeOwned>(
304        &self,
305        index: usize,
306        input_bytes: &[u8],
307    ) -> Result<O, CallError> {
308        // Read the slot as `Option<fn>`: an optional method the plugin did not
309        // implement has a NULL vtable slot — never call into it (would segfault).
310        let fn_ptr = match unsafe { *(self.vtable as *const Option<FfiFn>).add(index) } {
311            Some(f) => f,
312            None => return Err(CallError::NotImplemented { bit: index as u32 }),
313        };
314
315        let mut out_ptr: *mut u8 = std::ptr::null_mut();
316        let mut out_len: u32 = 0;
317
318        let status = unsafe {
319            fn_ptr(
320                self.instance,
321                input_bytes.as_ptr(),
322                input_bytes.len() as u32,
323                &mut out_ptr,
324                &mut out_len,
325            )
326        };
327
328        match status {
329            STATUS_OK => {}
330            STATUS_BUFFER_TOO_SMALL => return Err(CallError::BufferTooSmall),
331            STATUS_SERIALIZATION_ERROR => {
332                return Err(CallError::Serialization("FFI serialization failed".into()))
333            }
334            STATUS_PLUGIN_ERROR => {
335                if !out_ptr.is_null() && out_len > 0 {
336                    let output_slice =
337                        unsafe { std::slice::from_raw_parts(out_ptr, out_len as usize) };
338                    let plugin_err: PluginError = wire::deserialize(output_slice)
339                        .map_err(|e| CallError::Deserialization(e.to_string()))?;
340
341                    if let Some(free) = self.free_buffer {
342                        unsafe { free(out_ptr, out_len as usize) };
343                    }
344
345                    return Err(CallError::Plugin(plugin_err));
346                }
347                return Err(CallError::Plugin(PluginError::new(
348                    "UNKNOWN",
349                    "plugin returned error but no error data",
350                )));
351            }
352            STATUS_PANIC => {
353                let msg = if !out_ptr.is_null() && out_len > 0 {
354                    let slice = unsafe { std::slice::from_raw_parts(out_ptr, out_len as usize) };
355                    let msg = wire::deserialize::<String>(slice)
356                        .unwrap_or_else(|_| "unknown panic".into());
357                    if let Some(free) = self.free_buffer {
358                        unsafe { free(out_ptr, out_len as usize) };
359                    }
360                    msg
361                } else {
362                    "unknown panic".into()
363                };
364                return Err(CallError::Panic(msg));
365            }
366            _ => return Err(CallError::UnknownStatus { code: status }),
367        }
368
369        if out_ptr.is_null() {
370            return Err(CallError::Serialization(
371                "plugin returned null output buffer".into(),
372            ));
373        }
374
375        let output_slice = unsafe { std::slice::from_raw_parts(out_ptr, out_len as usize) };
376        let result: Result<O, CallError> =
377            wire::deserialize(output_slice).map_err(|e| CallError::Deserialization(e.to_string()));
378
379        if let Some(free) = self.free_buffer {
380            unsafe { free(out_ptr, out_len as usize) };
381        }
382
383        result
384    }
385
386    /// Arena path: host supplies a buffer from the thread-local pool. If the
387    /// plugin reports `STATUS_BUFFER_TOO_SMALL`, grow the buffer to the
388    /// requested size and retry exactly once (second too-small would indicate
389    /// a misbehaving plugin — bail with `CallError::BufferTooSmall`).
390    fn call_arena<O: DeserializeOwned>(
391        &self,
392        index: usize,
393        input_bytes: &[u8],
394    ) -> Result<O, CallError> {
395        let fn_ptr = match unsafe { *(self.vtable as *const Option<ArenaFn>).add(index) } {
396            Some(f) => f,
397            None => return Err(CallError::NotImplemented { bit: index as u32 }),
398        };
399
400        let mut arena = acquire_arena(DEFAULT_ARENA_CAPACITY);
401        let mut out_offset: u32 = 0;
402        let mut out_len: u32 = 0;
403        let mut retried = false;
404
405        let status = loop {
406            let s = unsafe {
407                fn_ptr(
408                    self.instance,
409                    input_bytes.as_ptr(),
410                    input_bytes.len() as u32,
411                    arena.as_mut_ptr(),
412                    arena.len() as u32,
413                    &mut out_offset,
414                    &mut out_len,
415                )
416            };
417            if s == STATUS_BUFFER_TOO_SMALL && !retried {
418                // Plugin wrote the needed size into out_len. Grow and retry once.
419                let needed = out_len as usize;
420                grow_arena(&mut arena, needed);
421                retried = true;
422                continue;
423            }
424            break s;
425        };
426
427        match status {
428            STATUS_OK => {
429                let start = out_offset as usize;
430                let end = start + out_len as usize;
431                if end > arena.len() {
432                    release_arena(arena);
433                    return Err(CallError::Serialization(
434                        "plugin reported out_offset/out_len outside arena".into(),
435                    ));
436                }
437                let result = wire::deserialize(&arena[start..end])
438                    .map_err(|e| CallError::Deserialization(e.to_string()));
439                release_arena(arena);
440                result
441            }
442            STATUS_BUFFER_TOO_SMALL => {
443                release_arena(arena);
444                Err(CallError::BufferTooSmall)
445            }
446            STATUS_SERIALIZATION_ERROR => {
447                release_arena(arena);
448                Err(CallError::Serialization("FFI serialization failed".into()))
449            }
450            STATUS_PLUGIN_ERROR => {
451                let start = out_offset as usize;
452                let end = start + out_len as usize;
453                let plugin_err = if out_len > 0 && end <= arena.len() {
454                    wire::deserialize::<PluginError>(&arena[start..end]).unwrap_or_else(|_| {
455                        PluginError::new("UNKNOWN", "plugin returned malformed error")
456                    })
457                } else {
458                    PluginError::new("UNKNOWN", "plugin returned error but no error data")
459                };
460                release_arena(arena);
461                Err(CallError::Plugin(plugin_err))
462            }
463            STATUS_PANIC => {
464                // Arena strategy's panic path returns out_len = 0 (the arena
465                // might be too small for the panic message). Host can't
466                // recover a message; report an opaque panic.
467                release_arena(arena);
468                Err(CallError::Panic(
469                    "plugin panicked (message not transmitted via Arena strategy)".into(),
470                ))
471            }
472            code => {
473                release_arena(arena);
474                Err(CallError::UnknownStatus { code })
475            }
476        }
477    }
478
479    /// PluginAllocated raw path — same FFI shape as `call_plugin_allocated`,
480    /// but the success buffer is returned to the caller as-is rather than
481    /// fed to bincode.
482    fn call_plugin_allocated_raw(
483        &self,
484        index: usize,
485        input_bytes: &[u8],
486    ) -> Result<Vec<u8>, CallError> {
487        // Read the slot as `Option<fn>`: an optional method the plugin did not
488        // implement has a NULL vtable slot — never call into it (would segfault).
489        let fn_ptr = match unsafe { *(self.vtable as *const Option<FfiFn>).add(index) } {
490            Some(f) => f,
491            None => return Err(CallError::NotImplemented { bit: index as u32 }),
492        };
493
494        let mut out_ptr: *mut u8 = std::ptr::null_mut();
495        let mut out_len: u32 = 0;
496
497        let status = unsafe {
498            fn_ptr(
499                self.instance,
500                input_bytes.as_ptr(),
501                input_bytes.len() as u32,
502                &mut out_ptr,
503                &mut out_len,
504            )
505        };
506
507        match status {
508            STATUS_OK => {}
509            STATUS_BUFFER_TOO_SMALL => return Err(CallError::BufferTooSmall),
510            STATUS_SERIALIZATION_ERROR => {
511                return Err(CallError::Serialization("FFI serialization failed".into()))
512            }
513            STATUS_PLUGIN_ERROR => {
514                if !out_ptr.is_null() && out_len > 0 {
515                    let output_slice =
516                        unsafe { std::slice::from_raw_parts(out_ptr, out_len as usize) };
517                    let plugin_err: PluginError = wire::deserialize(output_slice)
518                        .map_err(|e| CallError::Deserialization(e.to_string()))?;
519                    if let Some(free) = self.free_buffer {
520                        unsafe { free(out_ptr, out_len as usize) };
521                    }
522                    return Err(CallError::Plugin(plugin_err));
523                }
524                return Err(CallError::Plugin(PluginError::new(
525                    "UNKNOWN",
526                    "plugin returned error but no error data",
527                )));
528            }
529            STATUS_PANIC => {
530                let msg = if !out_ptr.is_null() && out_len > 0 {
531                    let slice = unsafe { std::slice::from_raw_parts(out_ptr, out_len as usize) };
532                    let msg = wire::deserialize::<String>(slice)
533                        .unwrap_or_else(|_| "unknown panic".into());
534                    if let Some(free) = self.free_buffer {
535                        unsafe { free(out_ptr, out_len as usize) };
536                    }
537                    msg
538                } else {
539                    "unknown panic".into()
540                };
541                return Err(CallError::Panic(msg));
542            }
543            _ => return Err(CallError::UnknownStatus { code: status }),
544        }
545
546        if out_ptr.is_null() {
547            return Err(CallError::Serialization(
548                "plugin returned null output buffer".into(),
549            ));
550        }
551
552        // Copy the success bytes into a Vec, then free the plugin's buffer.
553        // This matches the existing Box<[u8]> ownership contract — the plugin
554        // owns the memory until `free_buffer` is called.
555        let output_slice = unsafe { std::slice::from_raw_parts(out_ptr, out_len as usize) };
556        let result = output_slice.to_vec();
557
558        if let Some(free) = self.free_buffer {
559            unsafe { free(out_ptr, out_len as usize) };
560        }
561
562        Ok(result)
563    }
564
565    /// Client-streaming raw call (FIDIUS-I-0030 CS2.2). The vtable slot is a
566    /// `ClientStreamFn` that also takes the host's producer `handle`, from which
567    /// the plugin pulls its `Stream<T>` argument. `input_bytes` is the bincode of
568    /// the **non-stream** args; the bincode of the method's result is returned. The
569    /// plugin's consumer frees `handle` via its `drop_fn` — the host must not.
570    ///
571    /// # Safety
572    /// `handle` must be a valid, exclusively-owned producer handle (e.g. from
573    /// [`crate::client_stream::host_producer_handle`]); it is consumed by the call.
574    #[cfg(feature = "streaming")]
575    pub unsafe fn call_client_streaming_raw(
576        &self,
577        index: usize,
578        handle: *mut fidius_core::stream_ffi::FidiusStreamHandle,
579        input_bytes: &[u8],
580    ) -> Result<Vec<u8>, CallError> {
581        if index >= self.method_count as usize {
582            return Err(CallError::InvalidMethodIndex {
583                index,
584                count: self.method_count,
585            });
586        }
587        type ClientStreamFn = unsafe extern "C" fn(
588            *mut c_void,
589            *mut fidius_core::stream_ffi::FidiusStreamHandle,
590            *const u8,
591            u32,
592            *mut *mut u8,
593            *mut u32,
594        ) -> i32;
595        let fn_ptr = match unsafe { *(self.vtable as *const Option<ClientStreamFn>).add(index) } {
596            Some(f) => f,
597            None => return Err(CallError::NotImplemented { bit: index as u32 }),
598        };
599
600        let mut out_ptr: *mut u8 = std::ptr::null_mut();
601        let mut out_len: u32 = 0;
602        let status = unsafe {
603            fn_ptr(
604                self.instance,
605                handle,
606                input_bytes.as_ptr(),
607                input_bytes.len() as u32,
608                &mut out_ptr,
609                &mut out_len,
610            )
611        };
612
613        match status {
614            STATUS_OK => {}
615            STATUS_SERIALIZATION_ERROR => {
616                return Err(CallError::Serialization("FFI serialization failed".into()))
617            }
618            STATUS_PLUGIN_ERROR => {
619                let err = if !out_ptr.is_null() && out_len > 0 {
620                    let slice = unsafe { std::slice::from_raw_parts(out_ptr, out_len as usize) };
621                    let pe: PluginError = wire::deserialize(slice)
622                        .unwrap_or_else(|_| PluginError::new("UNKNOWN", "plugin error"));
623                    if let Some(free) = self.free_buffer {
624                        unsafe { free(out_ptr, out_len as usize) };
625                    }
626                    pe
627                } else {
628                    PluginError::new("UNKNOWN", "plugin returned error but no data")
629                };
630                return Err(CallError::Plugin(err));
631            }
632            STATUS_PANIC => {
633                let msg = if !out_ptr.is_null() && out_len > 0 {
634                    let slice = unsafe { std::slice::from_raw_parts(out_ptr, out_len as usize) };
635                    let m = wire::deserialize::<String>(slice)
636                        .unwrap_or_else(|_| "unknown panic".into());
637                    if let Some(free) = self.free_buffer {
638                        unsafe { free(out_ptr, out_len as usize) };
639                    }
640                    m
641                } else {
642                    "unknown panic".into()
643                };
644                return Err(CallError::Panic(msg));
645            }
646            _ => return Err(CallError::UnknownStatus { code: status }),
647        }
648
649        if out_ptr.is_null() {
650            return Err(CallError::Serialization(
651                "plugin returned null output buffer".into(),
652            ));
653        }
654        let result = unsafe { std::slice::from_raw_parts(out_ptr, out_len as usize) }.to_vec();
655        if let Some(free) = self.free_buffer {
656            unsafe { free(out_ptr, out_len as usize) };
657        }
658        Ok(result)
659    }
660
661    /// Arena raw path — same FFI shape as `call_arena`, success bytes
662    /// returned as a `Vec<u8>` copied out of the arena.
663    fn call_arena_raw(&self, index: usize, input_bytes: &[u8]) -> Result<Vec<u8>, CallError> {
664        let fn_ptr = match unsafe { *(self.vtable as *const Option<ArenaFn>).add(index) } {
665            Some(f) => f,
666            None => return Err(CallError::NotImplemented { bit: index as u32 }),
667        };
668
669        let mut arena = acquire_arena(DEFAULT_ARENA_CAPACITY);
670        let mut out_offset: u32 = 0;
671        let mut out_len: u32 = 0;
672        let mut retried = false;
673
674        let status = loop {
675            let s = unsafe {
676                fn_ptr(
677                    self.instance,
678                    input_bytes.as_ptr(),
679                    input_bytes.len() as u32,
680                    arena.as_mut_ptr(),
681                    arena.len() as u32,
682                    &mut out_offset,
683                    &mut out_len,
684                )
685            };
686            if s == STATUS_BUFFER_TOO_SMALL && !retried {
687                let needed = out_len as usize;
688                grow_arena(&mut arena, needed);
689                retried = true;
690                continue;
691            }
692            break s;
693        };
694
695        match status {
696            STATUS_OK => {
697                let start = out_offset as usize;
698                let end = start + out_len as usize;
699                if end > arena.len() {
700                    release_arena(arena);
701                    return Err(CallError::Serialization(
702                        "plugin reported out_offset/out_len outside arena".into(),
703                    ));
704                }
705                let result = arena[start..end].to_vec();
706                release_arena(arena);
707                Ok(result)
708            }
709            STATUS_BUFFER_TOO_SMALL => {
710                release_arena(arena);
711                Err(CallError::BufferTooSmall)
712            }
713            STATUS_SERIALIZATION_ERROR => {
714                release_arena(arena);
715                Err(CallError::Serialization("FFI serialization failed".into()))
716            }
717            STATUS_PLUGIN_ERROR => {
718                let start = out_offset as usize;
719                let end = start + out_len as usize;
720                let plugin_err = if out_len > 0 && end <= arena.len() {
721                    wire::deserialize::<PluginError>(&arena[start..end]).unwrap_or_else(|_| {
722                        PluginError::new("UNKNOWN", "plugin returned malformed error")
723                    })
724                } else {
725                    PluginError::new("UNKNOWN", "plugin returned error but no error data")
726                };
727                release_arena(arena);
728                Err(CallError::Plugin(plugin_err))
729            }
730            STATUS_PANIC => {
731                release_arena(arena);
732                Err(CallError::Panic(
733                    "plugin panicked (message not transmitted via Arena strategy)".into(),
734                ))
735            }
736            code => {
737                release_arena(arena);
738                Err(CallError::UnknownStatus { code })
739            }
740        }
741    }
742
743    /// Start a server-streaming cdylib call (FIDIUS-I-0026 CS.1). `input_bytes`
744    /// is the **concrete bincode** of the args tuple (the cdylib path never goes
745    /// through `Value` — same as `call_method`), so the caller serialises with
746    /// `wire::serialize` directly.
747    ///
748    /// Calls the streaming method's vtable slot (an *init* shim with the ordinary
749    /// `FfiFn` shape) to obtain a `FidiusStreamHandle`, then pumps `next()` on a
750    /// dedicated thread (cdylib is synchronous) into a bounded channel →
751    /// `ChunkStream`. The pump owns **one reusable buffer** the guest writes each
752    /// item into (FIDIUS-T-0138 arena-style `next`) — so there's no per-item heap
753    /// alloc and no `free_buffer` crossing, just one `next` call per item. Each
754    /// item crosses as **concrete bincode** and is turned into a `Value` by the
755    /// caller-supplied `decode_item` (`wire::deserialize::<O>` + `to_value`,
756    /// FIDIUS-T-0137). Dropping the stream runs the guest's `drop_fn` (cancel).
757    #[cfg(feature = "streaming")]
758    pub fn call_streaming_raw(
759        &self,
760        index: usize,
761        input_bytes: &[u8],
762        decode_item: fn(&[u8]) -> Result<fidius_core::Value, CallError>,
763    ) -> Result<crate::stream::ChunkStream, CallError> {
764        if index >= self.method_count as usize {
765            return Err(CallError::InvalidMethodIndex {
766                index,
767                count: self.method_count,
768            });
769        }
770
771        // init: the streaming method's vtable slot (FfiFn shape) → handle. Guard
772        // the null slot of an unimplemented optional method.
773        let init = match unsafe { *(self.vtable as *const Option<FfiFn>).add(index) } {
774            Some(f) => f,
775            None => return Err(CallError::NotImplemented { bit: index as u32 }),
776        };
777        let mut out_ptr: *mut u8 = std::ptr::null_mut();
778        let mut out_len: u32 = 0;
779        let status = unsafe {
780            init(
781                self.instance,
782                input_bytes.as_ptr(),
783                input_bytes.len() as u32,
784                &mut out_ptr,
785                &mut out_len,
786            )
787        };
788        match status {
789            STATUS_OK => {}
790            STATUS_SERIALIZATION_ERROR => {
791                return Err(CallError::Serialization(
792                    "stream init: argument decode failed".into(),
793                ))
794            }
795            STATUS_PANIC => return Err(CallError::Panic("plugin panicked in stream init".into())),
796            code => return Err(CallError::UnknownStatus { code }),
797        }
798        if out_ptr.is_null() {
799            return Err(CallError::Backend {
800                runtime: "cdylib".into(),
801                message: "stream init returned a null handle".into(),
802            });
803        }
804
805        // Pump the returned handle into a `ChunkStream` (shared with the
806        // bidirectional path, which reaches the same output-stream handle).
807        Ok(pump_stream_handle(out_ptr, decode_item))
808    }
809
810    /// Bidirectional streaming (FIDIUS-I-0032 / ADR-0010): call a method whose
811    /// vtable slot is a `ClientStreamFn` returning the OUTPUT stream handle. `handle`
812    /// is the host's INPUT producer (the plugin pulls its `Stream<In>` from it);
813    /// `input_bytes` is the bincode of the non-stream args. Returns a `ChunkStream`
814    /// over the plugin's `Stream<Out>` — pulling it drives the plugin, which
815    /// re-enters `handle.next()` on demand (the synchronous lazy-pull composition).
816    ///
817    /// # Safety
818    /// `handle` must be a valid, exclusively-owned producer handle; it is consumed
819    /// by the call (the plugin's output stream frees it via its `drop_fn`).
820    #[cfg(feature = "streaming")]
821    pub unsafe fn call_bidi_streaming_raw(
822        &self,
823        index: usize,
824        handle: *mut fidius_core::stream_ffi::FidiusStreamHandle,
825        input_bytes: &[u8],
826        decode_item: fn(&[u8]) -> Result<fidius_core::Value, CallError>,
827    ) -> Result<crate::stream::ChunkStream, CallError> {
828        if index >= self.method_count as usize {
829            return Err(CallError::InvalidMethodIndex {
830                index,
831                count: self.method_count,
832            });
833        }
834        // Same FFI shape as client-streaming: instance + input handle + args + out.
835        type ClientStreamFn = unsafe extern "C" fn(
836            *mut c_void,
837            *mut fidius_core::stream_ffi::FidiusStreamHandle,
838            *const u8,
839            u32,
840            *mut *mut u8,
841            *mut u32,
842        ) -> i32;
843        let init = match unsafe { *(self.vtable as *const Option<ClientStreamFn>).add(index) } {
844            Some(f) => f,
845            None => return Err(CallError::NotImplemented { bit: index as u32 }),
846        };
847        let mut out_ptr: *mut u8 = std::ptr::null_mut();
848        let mut out_len: u32 = 0;
849        let status = unsafe {
850            init(
851                self.instance,
852                handle,
853                input_bytes.as_ptr(),
854                input_bytes.len() as u32,
855                &mut out_ptr,
856                &mut out_len,
857            )
858        };
859        match status {
860            STATUS_OK => {}
861            STATUS_SERIALIZATION_ERROR => {
862                return Err(CallError::Serialization(
863                    "bidi stream init: argument decode failed".into(),
864                ))
865            }
866            STATUS_PANIC => {
867                return Err(CallError::Panic(
868                    "plugin panicked in bidi stream init".into(),
869                ))
870            }
871            code => return Err(CallError::UnknownStatus { code }),
872        }
873        if out_ptr.is_null() {
874            return Err(CallError::Backend {
875                runtime: "cdylib".into(),
876                message: "bidi stream init returned a null output handle".into(),
877            });
878        }
879        Ok(pump_stream_handle(out_ptr, decode_item))
880    }
881
882    /// Check if an optional method is supported (capability bit is set).
883    ///
884    /// Returns `false` for bit indices >= 64 rather than panicking.
885    pub fn has_capability(&self, bit: u32) -> bool {
886        if bit >= 64 {
887            return false;
888        }
889        self.capabilities & (1u64 << bit) != 0
890    }
891
892    /// Access the plugin's owned metadata.
893    pub fn info(&self) -> &PluginInfo {
894        &self.info
895    }
896
897    /// Returns the static key/value metadata declared on the given method via
898    /// `#[method_meta(...)]` attributes on the trait, in declaration order.
899    ///
900    /// Returns an empty `Vec` if:
901    /// - `method_id >= method_count` (out of range)
902    /// - the interface declared no method metadata on any method
903    /// - this specific method has no metadata declared
904    ///
905    /// The returned `&str` slices borrow from the loaded library's `.rodata`
906    /// (for dylib-loaded handles) or from the current binary's `.rodata`
907    /// (for in-process handles). The handle's lifetime bounds them safely.
908    pub fn method_metadata(&self, method_id: u32) -> Vec<(&str, &str)> {
909        if method_id >= self.method_count {
910            return Vec::new();
911        }
912        // SAFETY: descriptor pointer is valid for the handle's lifetime.
913        let desc = unsafe { &*self.descriptor };
914        if desc.method_metadata.is_null() {
915            return Vec::new();
916        }
917        // SAFETY: when method_metadata is non-null, it points at an array
918        // of method_count entries (codegen invariant).
919        let entries =
920            unsafe { std::slice::from_raw_parts(desc.method_metadata, self.method_count as usize) };
921        let entry = &entries[method_id as usize];
922        if entry.kvs.is_null() || entry.kv_count == 0 {
923            return Vec::new();
924        }
925        // SAFETY: kvs points at an array of kv_count MetaKv entries.
926        let kvs = unsafe { std::slice::from_raw_parts(entry.kvs, entry.kv_count as usize) };
927        kvs.iter()
928            .map(|kv| {
929                // SAFETY: both pointers are static, null-terminated UTF-8
930                // per the ABI contract enforced by the macro.
931                let k = unsafe { std::ffi::CStr::from_ptr(kv.key) }
932                    .to_str()
933                    .expect("metadata key is not valid UTF-8");
934                let v = unsafe { std::ffi::CStr::from_ptr(kv.value) }
935                    .to_str()
936                    .expect("metadata value is not valid UTF-8");
937                (k, v)
938            })
939            .collect()
940    }
941
942    /// Returns the static key/value metadata declared on the trait via
943    /// `#[trait_meta(...)]` attributes, in declaration order.
944    ///
945    /// Returns an empty `Vec` if no trait-level metadata was declared.
946    pub fn trait_metadata(&self) -> Vec<(&str, &str)> {
947        // SAFETY: descriptor pointer is valid for the handle's lifetime.
948        let desc = unsafe { &*self.descriptor };
949        if desc.trait_metadata.is_null() || desc.trait_metadata_count == 0 {
950            return Vec::new();
951        }
952        // SAFETY: trait_metadata points at an array of trait_metadata_count entries.
953        let kvs = unsafe {
954            std::slice::from_raw_parts(desc.trait_metadata, desc.trait_metadata_count as usize)
955        };
956        kvs.iter()
957            .map(|kv| {
958                let k = unsafe { std::ffi::CStr::from_ptr(kv.key) }
959                    .to_str()
960                    .expect("trait metadata key is not valid UTF-8");
961                let v = unsafe { std::ffi::CStr::from_ptr(kv.value) }
962                    .to_str()
963                    .expect("trait metadata value is not valid UTF-8");
964                (k, v)
965            })
966            .collect()
967    }
968}
969
970impl PluginExecutor for CdylibExecutor {
971    fn info(&self) -> &PluginInfo {
972        &self.info
973    }
974
975    fn method_count(&self) -> u32 {
976        self.method_count
977    }
978
979    /// Raw byte dispatch. This is also the carrier for the cdylib *typed* path:
980    /// [`CdylibExecutor::call_method`] bincode-wraps the concrete type and the
981    /// `PluginHandle` wrapper routes typed cdylib calls through `call_method`
982    /// directly, so the bytes a plugin receives are unchanged from pre-refactor.
983    fn call_raw(&self, method: usize, input: &[u8]) -> Result<Vec<u8>, CallError> {
984        self.call_method_raw(method, input)
985    }
986}
987
988/// Pump a returned `FidiusStreamHandle` into a [`crate::stream::ChunkStream`] on a
989/// dedicated thread (cdylib is synchronous), into a bounded channel for
990/// backpressure. Shared by server-streaming ([`CdylibExecutor::call_streaming_raw`])
991/// and bidirectional ([`CdylibExecutor::call_bidi_streaming_raw`]) — both reach the
992/// same output-stream handle shape; only the init shim that produced it differs.
993/// Each item crosses as concrete bincode and is lifted to a `Value` by `decode_item`
994/// (FIDIUS-T-0137). One reusable buffer per stream (FIDIUS-T-0138); dropping the
995/// stream runs the guest's `drop_fn` (cancel).
996#[cfg(feature = "streaming")]
997fn pump_stream_handle(
998    out_ptr: *mut u8,
999    decode_item: fn(&[u8]) -> Result<fidius_core::Value, CallError>,
1000) -> crate::stream::ChunkStream {
1001    use fidius_core::stream_ffi::FidiusStreamHandle;
1002    use fidius_core::Value;
1003
1004    /// Bounded backpressure/memory window between the pump thread and the async
1005    /// consumer (mirrors the Python/WASM bridges).
1006    const STREAM_CHANNEL_CAP: usize = 4;
1007
1008    // Send-wrap the raw handle for the pump thread (single-owner for the
1009    // stream's lifetime).
1010    struct SendHandle(*mut FidiusStreamHandle);
1011    unsafe impl Send for SendHandle {}
1012    let send_handle = SendHandle(out_ptr as *mut FidiusStreamHandle);
1013
1014    let (tx, rx) = tokio::sync::mpsc::channel::<Result<Value, CallError>>(STREAM_CHANNEL_CAP);
1015
1016    std::thread::spawn(move || {
1017        // Force capture of the whole `SendHandle` (which is `Send`), not the
1018        // disjoint raw-pointer field (2021 edition closure capture).
1019        let send_handle = send_handle;
1020        let handle = send_handle.0;
1021
1022        // ONE reusable buffer for the whole stream (FIDIUS-T-0138): the guest
1023        // writes each item into it, so there's no per-item heap alloc and no
1024        // `free_buffer` FFI crossing — just one `next` call per item. Grows on
1025        // demand when the guest reports BUFFER_TOO_SMALL.
1026        const INITIAL_ITEM_CAP: usize = 64;
1027        let mut buf = vec![0u8; INITIAL_ITEM_CAP];
1028
1029        loop {
1030            let next = unsafe { (*handle).next };
1031            let mut out_len: u32 = 0;
1032            let mut status =
1033                unsafe { next(handle, buf.as_mut_ptr(), buf.len() as u32, &mut out_len) };
1034            if status == STATUS_BUFFER_TOO_SMALL {
1035                // Guest reported the size it needs; grow + retry once. The guest
1036                // retains the serialized item across the retry, so nothing is lost.
1037                buf.resize(out_len as usize, 0);
1038                status = unsafe { next(handle, buf.as_mut_ptr(), buf.len() as u32, &mut out_len) };
1039            }
1040            match status {
1041                STATUS_OK => {
1042                    let item = decode_item(&buf[..out_len as usize]);
1043                    let is_err = item.is_err();
1044                    if tx.blocking_send(item).is_err() {
1045                        break; // consumer dropped → cancel
1046                    }
1047                    if is_err {
1048                        break;
1049                    }
1050                }
1051                STATUS_STREAM_END => break,
1052                STATUS_PLUGIN_ERROR => {
1053                    let pe = if out_len > 0 {
1054                        wire::deserialize::<PluginError>(&buf[..out_len as usize]).unwrap_or_else(
1055                            |_| PluginError::new("UNKNOWN", "malformed stream error"),
1056                        )
1057                    } else {
1058                        PluginError::new("UNKNOWN", "stream error without data")
1059                    };
1060                    let _ = tx.blocking_send(Err(CallError::Plugin(pe)));
1061                    break;
1062                }
1063                STATUS_BUFFER_TOO_SMALL => {
1064                    // Still too small after the grow-and-retry — misbehaving guest.
1065                    let _ = tx.blocking_send(Err(CallError::BufferTooSmall));
1066                    break;
1067                }
1068                STATUS_PANIC => {
1069                    let _ = tx.blocking_send(Err(CallError::Panic(
1070                        "plugin panicked in stream next".into(),
1071                    )));
1072                    break;
1073                }
1074                code => {
1075                    let _ = tx.blocking_send(Err(CallError::UnknownStatus { code }));
1076                    break;
1077                }
1078            }
1079        }
1080        // Run the guest destructor + free the handle (exactly once).
1081        unsafe {
1082            let drop_fn = (*handle).drop_fn;
1083            drop_fn(handle);
1084        }
1085    });
1086
1087    let body = futures::stream::unfold(rx, |mut rx| async move {
1088        rx.recv().await.map(|item| (item, rx))
1089    });
1090    crate::stream::ChunkStream::new(body)
1091}