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
//! End-to-end load → ABI-verify → metadata → instantiate → process → unload
//! lifecycle for the `test_plugin` cdylib example.
//!
//! This is the headline integration test for Task 4a: it drives the *real*
//! `dlopen` path of `PluginLoader` against a real `.so` built from
//! `examples/test_plugin.rs`, proving the full plugin lifecycle works on the
//! host platform (Linux in CI; the FreeBSD sandbox path is exercised by
//! Layer 4 VM regression).

mod common;

use audio_core_bsd::{AudioFrame, ProcessContext};
use audio_plugin_bsd::{is_abi_compatible, PluginLoader, AUDIO_PLUGIN_ABI_VERSION};

/// Exact-enough float comparison (uses `<`, never `==`, to stay
/// `clippy::float_cmp`-clean).
fn approx_eq(a: f32, b: f32) -> bool {
    (a - b).abs() < 1e-6
}

/// The full plugin lifecycle against the real `test_plugin` cdylib.
///
/// Exercises every stage the 0.1.0 loader exposes:
///
/// 1. `cargo build --example test_plugin` produces a loadable `.so`.
/// 2. `PluginLoader::load` `dlopen`s it, verifies the ABI magic + version
///    (major must match the host), and copies out the metadata.
/// 3. The copied metadata matches the static identity declared in the example.
/// 4. `instantiate` yields a `Box<dyn AudioNode>`; its `process` is the 0.1.0
///    sample pass-through, so the output frame must equal the input frame.
/// 5. After the node is dropped (releasing the opaque handle via
///    `audio_plugin_destroy`), `unload` closes the library (`dlclose`) and the
///    id is no longer registered.
#[test]
fn load_abi_verify_metadata_instantiate_process_unload() {
    // 1. Build (or reuse) the cdylib test plugin.
    let so = common::ensure_test_plugin_built();

    // 2. Load: dlopen + ABI magic + ABI version (major) check.
    let mut loader = PluginLoader::new();
    let id = loader
        .load(&so)
        .expect("plugin should load with a matching ABI magic + major version");

    // 3. Metadata: copied-out owned strings + ABI version word.
    let meta = loader.metadata(id).expect("loaded plugin has metadata");
    assert_eq!(meta.name, "test-plugin");
    assert_eq!(meta.version, "0.1.0");
    assert_eq!(meta.description, "cdylib test plugin");
    assert!(
        is_abi_compatible(AUDIO_PLUGIN_ABI_VERSION, meta.abi_version),
        "metadata abi_version {:#010x} must be major-compatible with host {:#010x}",
        meta.abi_version,
        AUDIO_PLUGIN_ABI_VERSION,
    );

    // 4. Instantiate: Box<dyn AudioNode> backed by the opaque handle.
    let mut node = loader
        .instantiate(id)
        .expect("loaded plugin instantiates an AudioNode");

    // 5. Process: the 0.1.0 adapter is a sample pass-through — the output
    //    frame must equal the input frame.
    let mut ctx = ProcessContext::new(8, 0, 48_000);
    let input =
        AudioFrame::from_planar(1, 48_000, vec![0.1, -0.2, 0.3, -0.4, 0.5, -0.6, 0.7, -0.8]);
    let mut outputs = [AudioFrame::silence(1, 8, 48_000)];
    node.process(&mut ctx, std::slice::from_ref(&input), &mut outputs);
    let out = &outputs[0];
    assert_eq!(out.samples.len(), input.samples.len());
    for (got, want) in out.samples.iter().zip(input.samples.iter()) {
        assert!(
            approx_eq(*got, *want),
            "pass-through output sample mismatch: got {got}, want {want}"
        );
    }

    // 6. Unload: the adapter (and its opaque handle) must be released *before*
    //    the library is closed, otherwise the destroy call would dereference
    //    unmapped memory.
    drop(node);
    assert!(loader.is_loaded(id), "still loaded before unload");
    loader.unload(id).expect("unload drops the library");
    assert!(!loader.is_loaded(id), "no longer loaded after unload");
}

/// Loading the same path twice yields two distinct ids, each with its own
/// `dlopen` handle (the loader does not deduplicate by path).
#[test]
fn loading_same_path_twice_yields_distinct_ids() {
    let so = common::ensure_test_plugin_built();
    let mut loader = PluginLoader::new();

    let first = loader.load(&so).expect("first load");
    let second = loader.load(&so).expect("second load");
    assert_ne!(first, second, "each load receives a fresh id");
    assert_eq!(loader.len(), 2);

    // Both ids resolve to the same metadata.
    assert_eq!(loader.metadata(first).unwrap().name, "test-plugin");
    assert_eq!(loader.metadata(second).unwrap().name, "test-plugin");

    // Unloading the first must not invalidate the second.
    loader.unload(first).expect("unload first");
    assert!(!loader.is_loaded(first));
    assert!(loader.is_loaded(second), "second survives first's unload");
    loader.unload(second).expect("unload second");
    assert!(loader.is_empty());
}