audio-plugin-bsd 0.1.1

Dynamic .so audio-plugin loader with ABI verification and FreeBSD Capsicum/pdfork per-process sandboxing for real-time audio in Rust
Documentation
//! The host-side [`Plugin`] trait, the [`HostPlugin`] adapter that bridges the
//! C ABI to it, and the [`AudioNodeAdapter`] that wraps an opaque plugin handle
//! behind [`audio_core_bsd::AudioNode`].
//!
//! See [`crate`] for why plugins cross the `.so` boundary as `extern "C"`
//! symbols rather than Rust trait objects.

use std::ffi::c_void;

use audio_core_bsd::{
    AudioFrame, AudioNode, PortDescriptor, PortDirection, ProcessContext, SampleFormat,
};

use crate::metadata::PluginMetadata;
use crate::symbols::{CreateFn, DestroyFn};

/// Host-side abstraction over a loaded plugin.
///
/// `Plugin` is the trait the host calls after the ABI check passes. The
/// loader's own implementation ([`HostPlugin`]) bridges the plugin's
/// `extern "C"` entry symbols to this trait, but consumers that build their
/// own plugin adapters (e.g. for in-process test plugins) may implement it
/// directly.
///
/// `Send + Sync` is required so a [`PluginLoader`](crate::PluginLoader) can be
/// moved between setup threads; instantiation produces a `Box<dyn AudioNode>`
/// (which is only `Send`) for the single RT thread.
pub trait Plugin: Send + Sync {
    /// Static identity of this plugin (name, version, description, ...).
    fn metadata(&self) -> &PluginMetadata;

    /// Construct a fresh audio-node instance backed by this plugin.
    ///
    /// Each call yields an independent `Box<dyn AudioNode>` whose lifetime is
    /// bounded by the caller; dropping it releases the opaque handle (when the
    /// plugin exposes `audio_plugin_destroy`).
    fn instantiate(&self) -> Box<dyn AudioNode>;
}

/// Host adapter that bridges a plugin's `extern "C"` entry symbols to the
/// [`Plugin`] trait.
///
/// Constructed by [`PluginLoader`](crate::PluginLoader) after a successful ABI
/// check; not intended to be built by external callers.
pub(crate) struct HostPlugin {
    metadata: PluginMetadata,
    create_fn: CreateFn,
    destroy_fn: Option<DestroyFn>,
}

impl HostPlugin {
    /// Build a host adapter from verified entry symbols.
    pub(crate) fn new(
        metadata: PluginMetadata,
        create_fn: CreateFn,
        destroy_fn: Option<DestroyFn>,
    ) -> Self {
        Self {
            metadata,
            create_fn,
            destroy_fn,
        }
    }
}

impl Plugin for HostPlugin {
    fn metadata(&self) -> &PluginMetadata {
        &self.metadata
    }

    fn instantiate(&self) -> Box<dyn AudioNode> {
        // SAFETY: `create_fn` is the plugin's verified `audio_plugin_create`
        // entry symbol. The plugin is responsible for returning a valid,
        // non-aliased opaque handle (or null, which the adapter treats as a
        // no-op handle). The plugin remains loaded for at least as long as the
        // returned adapter because the loader owns the `Library`.
        let handle = unsafe { (self.create_fn)() };
        Box::new(AudioNodeAdapter::new(handle, self.destroy_fn))
    }
}

/// `AudioNode` wrapper around an opaque plugin handle.
///
/// # 0.1.0 scope — pass-through
///
/// The 0.1.0 ABI defines `create`/`destroy` but not a per-cycle `process`
/// dispatch protocol on the opaque handle. Accordingly this adapter's
/// [`AudioNode::process`] is a **sample pass-through**: it copies the first
/// input frame's samples into the first output frame unchanged. This lets the
/// Task 4 integration test verify the full load → ABI check → instantiate →
/// process → unload lifecycle without requiring a plugin DSP protocol.
///
/// A follow-up milestone will define the opaque-handle C process protocol and
/// replace this pass-through with real DSP dispatch.
pub(crate) struct AudioNodeAdapter {
    /// Opaque handle returned by `audio_plugin_create` (may be null).
    handle: *mut c_void,
    /// Matching deallocator (optional; absent ⇒ leak on drop).
    destroy_fn: Option<DestroyFn>,
    /// Single mono F32 input port.
    in_port: [PortDescriptor; 1],
    /// Single mono F32 output port.
    out_port: [PortDescriptor; 1],
}

// SAFETY: the opaque handle is owned solely by this adapter and is only
// touched from `process` (RT thread) and `Drop` (RT or setup thread), never
// concurrently. The graph engine guarantees a single RT thread, and the
// adapter is moved onto it by value. Function pointers (`Option<DestroyFn>`)
// are `Send + Sync`. Hence the adapter as a whole is safe to send.
unsafe impl Send for AudioNodeAdapter {}

impl AudioNodeAdapter {
    /// Build a pass-through adapter around an opaque handle.
    pub(crate) fn new(handle: *mut c_void, destroy_fn: Option<DestroyFn>) -> Self {
        Self {
            handle,
            destroy_fn,
            in_port: [PortDescriptor::new(
                PortDirection::Input,
                1,
                SampleFormat::F32,
            )],
            out_port: [PortDescriptor::new(
                PortDirection::Output,
                1,
                SampleFormat::F32,
            )],
        }
    }
}

impl AudioNode for AudioNodeAdapter {
    fn inputs(&self) -> &[PortDescriptor] {
        &self.in_port
    }

    fn outputs(&self) -> &[PortDescriptor] {
        &self.out_port
    }

    fn process(
        &mut self,
        _ctx: &mut ProcessContext,
        in_frames: &[AudioFrame],
        out_frames: &mut [AudioFrame],
    ) {
        // RT-safe: bounded slicing, no alloc / lock / panic. The handle is not
        // dispatched to in 0.1.0 (see the struct doc for the pass-through
        // rationale); it is held only for lifecycle correctness.
        let _ = self.handle;
        let Some(inp) = in_frames.first() else {
            return;
        };
        let Some(out) = out_frames.get_mut(0) else {
            return;
        };
        let n = inp.samples.len().min(out.samples.len());
        out.samples[..n].copy_from_slice(&inp.samples[..n]);
    }
}

impl Drop for AudioNodeAdapter {
    fn drop(&mut self) {
        if let Some(destroy) = self.destroy_fn {
            // SAFETY: `self.handle` was obtained from `audio_plugin_create`
            // exactly once and has not been freed before. `destroy` is the
            // plugin's matching deallocator. A null handle is passed through;
            // the plugin's destroy must tolerate it (or the plugin must not
            // return null from create).
            unsafe { destroy(self.handle) };
        }
        // When `destroy_fn` is `None` the handle leaks — a documented 0.1.0
        // limitation of plugins that do not expose `audio_plugin_destroy`.
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn adapter_is_pass_through_preserving_samples() {
        let mut adapter = AudioNodeAdapter::new(std::ptr::null_mut(), None);
        let mut ctx = ProcessContext::new(4, 0, 48_000);
        let input = AudioFrame::from_planar(1, 48_000, vec![0.1, 0.2, -0.3, 0.5]);
        let mut output = AudioFrame::silence(1, 4, 48_000);
        let mut out_slice = [output];
        adapter.process(&mut ctx, [input.clone()].as_slice(), &mut out_slice);
        output = out_slice.into_iter().next().unwrap();
        for (a, b) in output.samples.iter().zip(input.samples.iter()) {
            assert!(((a - b).abs() < 1e-6));
        }
    }

    #[test]
    fn adapter_declares_one_mono_f32_input_and_output() {
        let adapter = AudioNodeAdapter::new(std::ptr::null_mut(), None);
        assert_eq!(adapter.inputs().len(), 1);
        assert_eq!(adapter.outputs().len(), 1);
        assert_eq!(adapter.inputs()[0].channels, 1);
        assert_eq!(adapter.inputs()[0].sample_format, SampleFormat::F32);
        assert_eq!(adapter.outputs()[0].direction, PortDirection::Output);
    }

    #[test]
    fn adapter_process_with_empty_frames_does_not_panic() {
        let mut adapter = AudioNodeAdapter::new(std::ptr::null_mut(), None);
        let mut ctx = ProcessContext::new(0, 0, 48_000);
        adapter.process(&mut ctx, &[], &mut []);
    }

    #[test]
    fn adapter_process_clamps_to_shorter_buffer() {
        // Input has 2 samples, output has room for 4 — only 2 are written.
        let mut adapter = AudioNodeAdapter::new(std::ptr::null_mut(), None);
        let mut ctx = ProcessContext::new(2, 0, 48_000);
        let input = AudioFrame::from_planar(1, 48_000, vec![0.5, -0.5]);
        let mut output = AudioFrame::silence(1, 4, 48_000);
        let mut out_slice = [output];
        adapter.process(&mut ctx, [input].as_slice(), &mut out_slice);
        output = out_slice.into_iter().next().unwrap();
        assert!(((output.samples[0] - 0.5).abs() < 1e-6));
        assert!(((output.samples[1] + 0.5).abs() < 1e-6));
        // Untouched tail stays zero.
        assert!(((output.samples[2]).abs() < 1e-6));
        assert!(((output.samples[3]).abs() < 1e-6));
    }
}