FFI System
This article describes the plugin system Cloacina uses to dynamically load and execute workflow packages. Cloacina uses fidius, a framework that transforms a Rust trait into a stable C ABI plugin, eliminating the need for hand-written extern "C" functions and #[repr(C)] structs.
Workflow packages are compiled as cdylib shared libraries. At runtime, Cloacina’s host loader opens each library and dispatches calls through a single well-known entry point. The fidius framework sits between the host and the plugin, handling:
- Serialization and deserialization of method arguments and return values
- Panic catching so a panicking plugin cannot crash the host process
- Buffer management with automatic allocation on both sides of the boundary
- ABI validation to detect version drift before any calls are made
The interface contract is defined in cloacina-workflow-plugin, a small crate shared by both the plugin author and the host. It declares the CloacinaPlugin trait using the #[plugin_interface] attribute from fidius:
#[plugin_interface]
pub trait CloacinaPlugin {
fn get_task_metadata(&self) -> PackageTasksMetadata;
fn execute_task(&self, request: TaskExecutionRequest) -> TaskExecutionResult;
}
This crate is the single source of truth for the interface. Both the plugin and the host depend on exactly this crate, which ensures they agree on method signatures, type layouts, and the ABI hash fidius derives from the trait definition.
The types that cross the FFI boundary are plain Rust structs that derive serde::Serialize and serde::Deserialize:
PackageTasksMetadata— package name, task list, dependency graph; returned byget_task_metadataTaskExecutionRequest— task name and serialized context; passed toexecute_taskTaskExecutionResult— success/error status and updated context; returned fromexecute_task
Because fidius serializes these types rather than passing raw pointers, there are no *const c_char fields or manual CStr conversions.
The #[workflow] macro, when building for a cdylib target, generates two things:
- An
impl CloacinaPluginblock that dispatchesget_task_metadataandexecute_taskto the workflow’s actual task functions. - The fidius registration boilerplate —
#[plugin_impl(CloacinaPlugin)]on the impl and afidius_plugin_registry!()call that exports thefidius_get_registrysymbol.
Plugin authors do not write any of this by hand. The macro output is equivalent to:
#[plugin_impl(CloacinaPlugin)]
impl CloacinaPlugin for DataProcessingPlugin {
fn get_task_metadata(&self) -> PackageTasksMetadata {
// returns statically-known metadata for the workflow
}
fn execute_task(&self, request: TaskExecutionRequest) -> TaskExecutionResult {
// dispatches to the requested task function
}
}
fidius_plugin_registry!(DataProcessingPlugin);
The fidius_plugin_registry!() macro exports the single C symbol fidius_get_registry, which is the only symbol the host needs to locate.
The host (cloacina-ctl and the runtime) loads plugins using fidius_host::load_library():
let handle = fidius_host::load_library::<dyn CloacinaPlugin>(path)?;
Before returning the handle, fidius performs a sequence of validations:
- Magic bytes — confirms the library was built with fidius
- ABI version — checks the fidius framework version matches
- Interface hash — a hash derived from the
CloacinaPlugintrait definition; if the plugin was compiled against a different version ofcloacina-workflow-plugin, this check fails immediately - Wire format — confirms both sides agree on the serialization format
Once loaded, method calls go through PluginHandle::call_method(), which serializes arguments, calls across the boundary, deserializes the result, and surfaces any plugin panic as a Result::Err rather than unwinding into the host.
fidius uses different serialization formats depending on the build profile:
- Debug builds: JSON — human-readable, easy to inspect in logs
- Release builds: bincode — compact and fast
This is automatic and requires no configuration. Both the plugin and host switch format together because they share the same cloacina-workflow-plugin crate.
The fidius approach provides several safety properties that the previous hand-written FFI did not:
- No raw pointer fields: all data crosses the boundary as serialized bytes; there are no
*const c_charpointers for the caller to misuse or fail to free - ABI hash drift detection: a plugin compiled against an older interface crate is rejected at load time rather than silently calling the wrong method
- Panic isolation: plugin panics are caught at the boundary and returned as errors; the host process is never unwound by a plugin
- Automatic buffer sizing: fidius allocates exactly the right buffer for each call; there is no fixed-size buffer that could truncate large results