loadable_node_abi/lib.rs
1//! ABI-stable plugin interface.
2//!
3//! Both the host and the plugin depend on this crate. Everything that
4//! crosses the `dlopen` boundary is defined here and uses `abi_stable`
5//! types (`RVec`, `RString`, `RResult`, `RBox`, sabi-trait objects).
6//!
7//! `RuntimeData` itself is **not** in this surface — instead it is
8//! serialized to msgpack bytes (`RVec<u8>` via `rmp-serde::to_vec_named`)
9//! at the boundary. That keeps `remotemedia-core` out of the FFI
10//! contract entirely, so a plugin built against a different rustc /
11//! feature set can still load.
12//!
13//! For the full contract — wire format, versioning policy, plugin
14//! author rules, change history — see
15//! [`docs/LOADABLE_NODE_ABI.md`](../../../docs/LOADABLE_NODE_ABI.md).
16
17use abi_stable::{
18 declare_root_module_statics,
19 library::RootModule,
20 package_version_strings, sabi_trait,
21 sabi_types::VersionStrings,
22 std_types::{RBox, RErr, ROk, RResult, RString, RVec},
23 StableAbi,
24};
25use async_ffi::{FfiFuture, FutureExt};
26
27/// FFI-safe node.
28///
29/// `process` returns an `FfiFuture` — an ABI-stable future the host
30/// can `.await` directly. Plugin-side async runtimes (or none) work as
31/// long as the future polls to completion without referencing
32/// runtime-specific globals; in practice, plugins that call back into
33/// host services do so by polling synchronous state from the async
34/// block.
35///
36/// # Forward compatibility (multi-output extension)
37///
38/// `process` is marked `#[sabi(last_prefix_field)]` — that's the cut
39/// between the original FFI surface (1.x) and any methods added in
40/// minor versions. Methods added below it must carry a default impl
41/// so older plugins (whose vtables only expose `process`) continue to
42/// load. Hosts compiled against the newer ABI then transparently fall
43/// back to the default whenever a plugin omits the new method.
44///
45/// `process_multi` is the multi-output sibling of `process`: a node's
46/// `process_streaming` callback can fire N times per input (think
47/// SileroVAD emitting `Json(event)` plus the audio passthrough), and
48/// the single-output `process` would silently drop everything but the
49/// first emission. `process_multi` returns the full `RVec` so the
50/// host can dispatch each blob into the streaming callback chain.
51///
52/// The default impl wraps `process` as a 1-element `RVec` so plugins
53/// that only implement single-output stay correct (just lossy when
54/// the underlying node was actually multi-output — same behaviour as
55/// before this method existed).
56#[sabi_trait]
57pub trait FfiNode: Send + Sync + 'static {
58 fn node_type(&self) -> RString;
59
60 #[sabi(last_prefix_field)]
61 fn process(&self, input: RVec<u8>) -> FfiFuture<RResult<RVec<u8>, RString>>;
62
63 /// Multi-output process. Returns ALL emissions from one input as
64 /// a flat `RVec<RVec<u8>>` (each inner vec is one rmp-serde
65 /// `RuntimeData` blob, in emission order).
66 ///
67 /// Default impl: delegate to `process` and wrap its single output
68 /// as a 1-element vec. Plugins that haven't been rebuilt against
69 /// the multi-output ABI keep working — just without multi-output
70 /// semantics. Plugins that override this method get full N-output
71 /// fidelity through the `LoadableNodeAdapter` host wiring.
72 fn process_multi(&self, input: RVec<u8>) -> FfiFuture<RResult<RVec<RVec<u8>>, RString>> {
73 let fut = self.process(input);
74 async move {
75 match fut.await {
76 ROk(out) => ROk(RVec::from(vec![out])),
77 RErr(e) => RErr(e),
78 }
79 }
80 .into_ffi()
81 }
82
83 /// Per-frame streaming process. Same wire format as `process_multi`
84 /// (msgpack-encoded `RuntimeData` blobs in emission order) but each
85 /// frame is delivered to `sink` *as it arrives* rather than
86 /// accumulated and returned at the end. Returns the total emission
87 /// count on completion.
88 ///
89 /// Why this exists: `process_multi` collects every emission into
90 /// an `RVec<RVec<u8>>` before returning, so a streaming node that
91 /// yields audio chunks over wall time (TTS, STT) emits nothing
92 /// downstream until the *whole* generation finishes, then bursts
93 /// every chunk at once. Real-time playback then perceives latency
94 /// equal to total generation time. `process_streaming` fixes this
95 /// by handing the plugin a [`OutputSinkBox`] that forwards each
96 /// emission immediately.
97 ///
98 /// Default impl: delegates to `process_multi` and pushes each
99 /// returned frame to the sink. Functionally correct but loses the
100 /// real-time benefit — plugins must override this method (in
101 /// practice via `LoadableNodeAdapter`, which forwards inside the
102 /// inner node's own callback) to actually stream. Plugins on
103 /// older ABI versions (vtable lacks this slot) fall back to the
104 /// default, preserving load compatibility.
105 fn process_streaming(
106 &self,
107 input: RVec<u8>,
108 sink: OutputSinkBox,
109 ) -> FfiFuture<RResult<usize, RString>> {
110 let fut = self.process_multi(input);
111 async move {
112 match fut.await {
113 ROk(outputs) => {
114 let mut count: usize = 0;
115 for out in outputs {
116 if let RErr(e) = sink.push(out) {
117 return RErr(e);
118 }
119 count += 1;
120 }
121 ROk(count)
122 }
123 RErr(e) => RErr(e),
124 }
125 }
126 .into_ffi()
127 }
128
129 /// One-time, per-session initialization hook for lazy-load plugins.
130 ///
131 /// Forwarded from the host's `AsyncStreamingNode::initialize()`
132 /// once per session, before the first `process` call. Plugins that
133 /// do all their work eagerly inside `FfiNodeFactory::create()`
134 /// (e.g. audio2face's `Audio2FaceLipSyncNode::load`, live2d-render's
135 /// `WgpuBackend::new`) can leave this defaulted. Plugins with a
136 /// non-trivial init (e.g. llama-cpp spawning a worker thread that
137 /// loads a multi-GB GGUF) must override it — without forwarding,
138 /// the worker is never spawned and `process` returns "worker not
139 /// running".
140 ///
141 /// `session_id` and `node_id` are forwarded as RStrings so the
142 /// plugin can log / tag work with them. `emit_progress` is NOT
143 /// forwarded today — progress events emitted from inside a
144 /// loadable plugin's `initialize()` are silently dropped. Plugin
145 /// authors who need progress visibility should wrap heavy init in
146 /// the host (e.g. via `WarmSessionPool::prewarm` which fires its
147 /// own progress before delegating).
148 ///
149 /// Default impl: no-op. Older plugins not rebuilt against this
150 /// method keep compiling, just without lazy-init semantics.
151 fn initialize(
152 &self,
153 _session_id: RString,
154 _node_id: RString,
155 ) -> FfiFuture<RResult<(), RString>> {
156 async { ROk(()) }.into_ffi()
157 }
158}
159
160/// Owned trait object for an FFI node.
161///
162/// `sabi_trait` drops the lifetime parameter when the trait has a
163/// `'static` bound, so the alias does not name a lifetime.
164pub type FfiNodeBox = FfiNode_TO<RBox<()>>;
165
166/// Per-frame output sink for streaming plugins.
167///
168/// Crosses the dlopen boundary like `FfiNode`. The host hands an
169/// [`OutputSinkBox`] to [`FfiNode::process_streaming`]; the plugin
170/// invokes `push()` once per emission, and the host forwards each
171/// blob into its own streaming callback chain (router → next node
172/// → transport).
173///
174/// `push` takes `&self` because every realistic implementation
175/// (tokio mpsc senders, lock-free queues) has interior mutability —
176/// forcing `&mut self` would just push the synchronisation into the
177/// plugin's callback closure.
178///
179/// `push` returns `RErr` if the host's receiver was dropped
180/// mid-stream (session shutdown, barge-in). Plugins should treat
181/// that as "consumer is gone, unwind ASAP" rather than retrying.
182#[sabi_trait]
183pub trait OutputSink: Send + Sync + 'static {
184 fn push(&self, bytes: RVec<u8>) -> RResult<(), RString>;
185}
186
187/// Owned trait object for an output sink.
188pub type OutputSinkBox = OutputSink_TO<RBox<()>>;
189
190/// FFI-safe factory — produces FfiNode instances from a JSON params blob.
191#[sabi_trait]
192pub trait FfiNodeFactory: Send + Sync + 'static {
193 fn node_type(&self) -> RString;
194 fn create(&self, params: RString) -> RResult<FfiNodeBox, RString>;
195}
196
197/// Owned trait object for an FFI factory.
198pub type FfiNodeFactoryBox = FfiNodeFactory_TO<RBox<()>>;
199
200/// Root module exported by every plugin.
201///
202/// abi_stable validates layout, abi_stable version, and prefix-type
203/// compatibility when the host calls `NodePluginRef::load_from_file`.
204#[repr(C)]
205#[derive(StableAbi)]
206#[sabi(kind(Prefix(prefix_ref = NodePluginRef)))]
207#[sabi(missing_field(panic))]
208pub struct NodePlugin {
209 /// Returns every factory this plugin provides.
210 #[sabi(last_prefix_field)]
211 pub list_factories: extern "C" fn() -> RVec<FfiNodeFactoryBox>,
212}
213
214impl RootModule for NodePluginRef {
215 declare_root_module_statics! {NodePluginRef}
216 const BASE_NAME: &'static str = "node_plugin";
217 const NAME: &'static str = "node_plugin";
218 const VERSION_STRINGS: VersionStrings = package_version_strings!();
219}