Skip to main content

audio_plugin_bsd/
loader.rs

1//! Dynamic plugin loader: [`PluginLoader`], [`LoadedPlugin`], and [`PluginId`].
2//!
3//! [`PluginLoader`] owns every loaded `cdylib` (`libloading::Library`) and
4//! hands out [`PluginId`]s that reference them. Unloading a plugin removes its
5//! entry, which drops the `Library` and runs `dlclose` — guaranteeing no
6//! outstanding [`AudioNode`](audio_core_bsd::AudioNode) instance can outlive
7//! the library it came from (the adapter is dropped first, releasing the
8//! opaque handle, and only then the library is closed).
9//!
10//! # Real-time safety boundary
11//!
12//! `load` / `unload` / `instantiate` run on a **non-RT** (setup) thread. Only
13//! the [`AudioNode`](audio_core_bsd::AudioNode) returned from
14//! [`PluginLoader::instantiate`] ever touches the RT thread.
15
16use std::collections::HashMap;
17use std::path::Path;
18
19use audio_core_bsd::AudioNode;
20use libloading::Library;
21
22use crate::abi::{is_abi_compatible, AUDIO_PLUGIN_ABI_MAGIC, AUDIO_PLUGIN_ABI_VERSION};
23use crate::error::{PluginError, Result};
24use crate::metadata::PluginMetadata;
25use crate::plugin::{HostPlugin, Plugin};
26use crate::symbols::{
27    raw_to_metadata, AbiMagicFn, AbiVersionFn, CreateFn, DestroyFn, MetadataFn,
28    AUDIO_PLUGIN_ABI_MAGIC_SYMBOL, AUDIO_PLUGIN_ABI_VERSION_SYMBOL, AUDIO_PLUGIN_CREATE_SYMBOL,
29    AUDIO_PLUGIN_DESTROY_SYMBOL, AUDIO_PLUGIN_METADATA_SYMBOL,
30};
31
32/// Opaque, copyable handle identifying one loaded plugin inside a
33/// [`PluginLoader`].
34///
35/// IDs are assigned monotonically starting at 0 and are unique within a single
36/// loader instance until `u32::MAX` is reached (after which they wrap). In
37/// practice the wrap is unreachable; it is handled without panicking for
38/// robustness.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub struct PluginId(u32);
41
42/// A successfully loaded plugin: its library, metadata, host adapter, and id.
43///
44/// The library is owned here so that dropping a `LoadedPlugin` runs `dlclose`
45/// after the adapter (and any instantiated node) has been released, preventing
46/// use-after-unload.
47pub struct LoadedPlugin {
48    /// The opened `cdylib`; never read directly — its sole purpose is to be
49    /// dropped (running `dlclose`) when the `LoadedPlugin` is removed, so the
50    /// plugin's symbols stay mapped for the adapter's lifetime.
51    #[allow(dead_code)]
52    library: Library,
53    /// Owned metadata copied out of the plugin at load time.
54    metadata: PluginMetadata,
55    /// Host adapter bridging the plugin's C symbols to [`Plugin`].
56    plugin: Box<dyn Plugin>,
57    /// The loader-assigned id.
58    id: PluginId,
59}
60
61impl LoadedPlugin {
62    /// Static metadata for this plugin.
63    #[must_use]
64    pub fn metadata(&self) -> &PluginMetadata {
65        &self.metadata
66    }
67
68    /// The id under which this plugin is registered.
69    #[must_use]
70    pub fn id(&self) -> PluginId {
71        self.id
72    }
73}
74
75/// Owner of all loaded plugin `cdylib`s.
76///
77/// See the [module docs](self) for the lifetime / RT-safety contract.
78pub struct PluginLoader {
79    plugins: HashMap<PluginId, LoadedPlugin>,
80    next_id: u32,
81}
82
83impl PluginLoader {
84    /// Construct an empty loader.
85    #[must_use]
86    pub fn new() -> Self {
87        Self {
88            plugins: HashMap::new(),
89            next_id: 0,
90        }
91    }
92
93    /// Load and ABI-verify a plugin `cdylib`, returning its assigned id.
94    ///
95    /// # Steps
96    ///
97    /// 1. Validate the path (must be an existing, readable file).
98    /// 2. `dlopen` the library.
99    /// 3. Look up and call `audio_plugin_abi_magic`; require
100    ///    [`AUDIO_PLUGIN_ABI_MAGIC`].
101    /// 4. Look up and call `audio_plugin_abi_version`; require a
102    ///    **major** match (see [`is_abi_compatible`]).
103    /// 5. Look up `audio_plugin_metadata` and copy out an owned
104    ///    [`PluginMetadata`].
105    /// 6. Look up `audio_plugin_create` (required) and `audio_plugin_destroy`
106    ///    (optional).
107    /// 7. Register the plugin under a fresh [`PluginId`].
108    ///
109    /// Loading the same path twice is permitted: each call receives its own id
110    /// and its own `dlopen` handle.
111    ///
112    /// # Errors
113    ///
114    /// - [`PluginError::InvalidPath`] — missing file or non-UTF-8 path.
115    /// - [`PluginError::LibraryLoad`] — `dlopen` failed.
116    /// - [`PluginError::SymbolMissing`] — a required symbol is absent.
117    /// - [`PluginError::InvalidMetadata`] — magic mismatch or bad metadata.
118    /// - [`PluginError::AbiVersionMismatch`] — ABI major differs.
119    pub fn load(&mut self, path: &Path) -> Result<PluginId> {
120        // 1. Path validation: must exist and be a file. UTF-8 is required so
121        //    that error messages can embed the path (see InvalidPath doc).
122        if !path.is_file() {
123            return Err(PluginError::InvalidPath(format!(
124                "{}: not an existing file",
125                path.display()
126            )));
127        }
128        if path.to_str().is_none() {
129            return Err(PluginError::InvalidPath(format!(
130                "{}: path is not valid UTF-8",
131                path.display()
132            )));
133        }
134
135        // 2. dlopen.
136        let library = unsafe { Library::new(path) }
137            .map_err(|e| PluginError::LibraryLoad(format!("{}: {e}", path.display())))?;
138
139        // 3. ABI magic.
140        let plugin_magic = unsafe {
141            let sym: libloading::Symbol<AbiMagicFn> = library
142                .get(AUDIO_PLUGIN_ABI_MAGIC_SYMBOL.as_bytes())
143                .map_err(|e| {
144                    PluginError::SymbolMissing(format!("{AUDIO_PLUGIN_ABI_MAGIC_SYMBOL}: {e}"))
145                })?;
146            sym()
147        };
148        if plugin_magic != AUDIO_PLUGIN_ABI_MAGIC {
149            return Err(PluginError::InvalidMetadata(format!(
150                "ABI magic mismatch: expected {AUDIO_PLUGIN_ABI_MAGIC:#010x}, got {plugin_magic:#010x}"
151            )));
152        }
153
154        // 4. ABI version (major must match host).
155        let plugin_version = unsafe {
156            let sym: libloading::Symbol<AbiVersionFn> = library
157                .get(AUDIO_PLUGIN_ABI_VERSION_SYMBOL.as_bytes())
158                .map_err(|e| {
159                    PluginError::SymbolMissing(format!("{AUDIO_PLUGIN_ABI_VERSION_SYMBOL}: {e}"))
160                })?;
161            sym()
162        };
163        if !is_abi_compatible(AUDIO_PLUGIN_ABI_VERSION, plugin_version) {
164            return Err(PluginError::AbiVersionMismatch {
165                host: AUDIO_PLUGIN_ABI_VERSION,
166                plugin: plugin_version,
167            });
168        }
169
170        // 5. Metadata (copy strings into owned memory).
171        let metadata = unsafe {
172            let sym: libloading::Symbol<MetadataFn> = library
173                .get(AUDIO_PLUGIN_METADATA_SYMBOL.as_bytes())
174                .map_err(|e| {
175                    PluginError::SymbolMissing(format!("{AUDIO_PLUGIN_METADATA_SYMBOL}: {e}"))
176                })?;
177            let raw_ptr = sym();
178            // SAFETY: `raw_ptr` is the plugin's `'static` metadata pointer;
179            // the library (and therefore the plugin's data segment) is alive
180            // because `library` is still in scope.
181            raw_to_metadata(raw_ptr)?
182        };
183
184        // 6. create (required) + destroy (optional).
185        let create_fn: CreateFn = unsafe {
186            let sym: libloading::Symbol<CreateFn> = library
187                .get(AUDIO_PLUGIN_CREATE_SYMBOL.as_bytes())
188                .map_err(|e| {
189                    PluginError::SymbolMissing(format!("{AUDIO_PLUGIN_CREATE_SYMBOL}: {e}"))
190                })?;
191            *sym
192        };
193        let destroy_fn: Option<DestroyFn> = unsafe {
194            library
195                .get::<DestroyFn>(AUDIO_PLUGIN_DESTROY_SYMBOL.as_bytes())
196                .ok()
197                // SAFETY: `Symbol<DestroyFn>` derefs to `&DestroyFn`; copying
198                // the plain function pointer out releases the library borrow.
199                .map(|sym| *sym)
200        };
201
202        // 7. Register.
203        let plugin: Box<dyn Plugin> =
204            Box::new(HostPlugin::new(metadata.clone(), create_fn, destroy_fn));
205        let id = self.fresh_id();
206        self.plugins.insert(
207            id,
208            LoadedPlugin {
209                library,
210                metadata,
211                plugin,
212                id,
213            },
214        );
215        Ok(id)
216    }
217
218    /// Unload a plugin, dropping its library (`dlclose`).
219    ///
220    /// The caller must ensure no [`AudioNode`](audio_core_bsd::AudioNode)
221    /// instantiated from this plugin is still alive — the adapter owns an
222    /// opaque handle into the plugin's memory and calling it after `dlclose`
223    /// is undefined behaviour.
224    ///
225    /// # Errors
226    ///
227    /// Returns [`PluginError::NotLoaded`] when `id` is not registered.
228    pub fn unload(&mut self, id: PluginId) -> Result<()> {
229        if self.plugins.remove(&id).is_some() {
230            Ok(())
231        } else {
232            Err(PluginError::NotLoaded)
233        }
234    }
235
236    /// Borrow a loaded plugin by id.
237    ///
238    /// # Errors
239    ///
240    /// Returns [`PluginError::NotLoaded`] when `id` is not registered.
241    pub fn get(&self, id: PluginId) -> Result<&LoadedPlugin> {
242        self.plugins.get(&id).ok_or(PluginError::NotLoaded)
243    }
244
245    /// Mutably borrow a loaded plugin by id.
246    ///
247    /// # Errors
248    ///
249    /// Returns [`PluginError::NotLoaded`] when `id` is not registered.
250    pub fn get_mut(&mut self, id: PluginId) -> Result<&mut LoadedPlugin> {
251        self.plugins.get_mut(&id).ok_or(PluginError::NotLoaded)
252    }
253
254    /// Metadata for a loaded plugin.
255    ///
256    /// # Errors
257    ///
258    /// Returns [`PluginError::NotLoaded`] when `id` is not registered.
259    pub fn metadata(&self, id: PluginId) -> Result<&PluginMetadata> {
260        Ok(self.get(id)?.metadata())
261    }
262
263    /// Instantiate a fresh [`AudioNode`] from a loaded plugin.
264    ///
265    /// Runs on the setup thread; the returned node is then moved onto the RT
266    /// thread by the caller.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`PluginError::NotLoaded`] when `id` is not registered.
271    pub fn instantiate(&mut self, id: PluginId) -> Result<Box<dyn AudioNode>> {
272        let loaded = self.get_mut(id)?;
273        Ok(loaded.plugin.instantiate())
274    }
275
276    /// `true` when `id` is currently loaded.
277    #[must_use]
278    pub fn is_loaded(&self, id: PluginId) -> bool {
279        self.plugins.contains_key(&id)
280    }
281
282    /// Number of plugins currently loaded.
283    #[must_use]
284    pub fn len(&self) -> usize {
285        self.plugins.len()
286    }
287
288    /// `true` when no plugins are loaded.
289    #[must_use]
290    pub fn is_empty(&self) -> bool {
291        self.plugins.is_empty()
292    }
293
294    /// All currently-registered plugin ids.
295    ///
296    /// The order is unspecified (iteration order of the internal map).
297    #[must_use]
298    pub fn ids(&self) -> Vec<PluginId> {
299        self.plugins.keys().copied().collect()
300    }
301
302    /// Allocate a fresh, never-zero id. Wraps at `u32::MAX` without panicking.
303    fn fresh_id(&mut self) -> PluginId {
304        let id = PluginId(self.next_id);
305        self.next_id = self.next_id.wrapping_add(1);
306        id
307    }
308}
309
310impl Default for PluginLoader {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn plugin_id_is_copy_eq_hash_debug() {
322        let a = PluginId(7);
323        let b = a; // Copy
324        assert_eq!(a, b);
325        assert_eq!(format!("{a:?}"), "PluginId(7)");
326
327        // Hash + Eq usable in a HashSet.
328        let mut set = std::collections::HashSet::new();
329        set.insert(a);
330        assert!(set.contains(&b));
331        assert!(!set.contains(&PluginId(8)));
332    }
333
334    #[test]
335    fn new_loader_is_empty() {
336        let loader = PluginLoader::new();
337        assert!(loader.is_empty());
338        assert_eq!(loader.len(), 0);
339        assert!(loader.ids().is_empty());
340    }
341
342    #[test]
343    fn default_equals_new() {
344        let d = PluginLoader::default();
345        assert!(d.is_empty());
346        assert_eq!(d.len(), 0);
347    }
348
349    #[test]
350    fn unload_unknown_id_is_not_loaded_err() {
351        let mut loader = PluginLoader::new();
352        let err = loader.unload(PluginId(0)).unwrap_err();
353        assert_eq!(err, PluginError::NotLoaded);
354    }
355
356    #[test]
357    fn get_unknown_id_is_not_loaded_err() {
358        let loader = PluginLoader::new();
359        // Map the `Ok(&LoadedPlugin)` to `Ok(())` so `unwrap_err` only needs
360        // `(): Debug` (LoadedPlugin intentionally does not impl Debug).
361        let err = loader.get(PluginId(42)).map(|_| ()).unwrap_err();
362        assert_eq!(err, PluginError::NotLoaded);
363    }
364
365    #[test]
366    fn get_mut_unknown_id_is_not_loaded_err() {
367        let mut loader = PluginLoader::new();
368        let err = loader.get_mut(PluginId(99)).map(|_| ()).unwrap_err();
369        assert_eq!(err, PluginError::NotLoaded);
370    }
371
372    #[test]
373    fn metadata_unknown_id_is_not_loaded_err() {
374        let loader = PluginLoader::new();
375        let err = loader.metadata(PluginId(5)).unwrap_err();
376        assert_eq!(err, PluginError::NotLoaded);
377    }
378
379    #[test]
380    fn instantiate_unknown_id_is_not_loaded_err() {
381        let mut loader = PluginLoader::new();
382        // Map the `Ok(Box<dyn AudioNode>)` to `Ok(())` so `unwrap_err` only
383        // needs `(): Debug` (dyn AudioNode does not impl Debug).
384        let err = loader.instantiate(PluginId(3)).map(|_| ()).unwrap_err();
385        assert_eq!(err, PluginError::NotLoaded);
386    }
387
388    #[test]
389    fn is_loaded_unknown_id_is_false() {
390        let loader = PluginLoader::new();
391        assert!(!loader.is_loaded(PluginId(0)));
392        assert!(!loader.is_loaded(PluginId(u32::MAX)));
393    }
394
395    #[test]
396    fn load_missing_file_is_invalid_path_err() {
397        let mut loader = PluginLoader::new();
398        let err = loader
399            .load(Path::new("/nonexistent/path/does-not-exist.so"))
400            .unwrap_err();
401        assert!(matches!(err, PluginError::InvalidPath(_)));
402        assert!(err.to_string().contains("not an existing file"));
403    }
404
405    #[test]
406    fn load_directory_is_invalid_path_err() {
407        let mut loader = PluginLoader::new();
408        // The crate's own parent directory definitely exists as a non-file.
409        let dir = Path::new(env!("CARGO_MANIFEST_DIR"));
410        let err = loader.load(dir).unwrap_err();
411        assert!(matches!(err, PluginError::InvalidPath(_)));
412        assert!(err.to_string().contains("not an existing file"));
413    }
414
415    #[test]
416    fn fresh_id_is_monotonic_and_does_not_panic_at_max() {
417        // Simulate the wrap directly: jump next_id to MAX and observe that the
418        // next allocation returns MAX and wraps to 0 without panicking.
419        let mut loader = PluginLoader::new();
420        loader.next_id = u32::MAX;
421        let first = loader.fresh_id();
422        assert_eq!(first, PluginId(u32::MAX));
423        // Wrapping add — no panic.
424        let second = loader.fresh_id();
425        assert_eq!(second, PluginId(0));
426    }
427}