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}