Skip to main content

truce_core/
wrapper.rs

1//! Helpers shared across format wrappers (CLAP, VST3, VST2, AU, AAX, LV2).
2//!
3//! Each wrapper still owns its format-specific descriptor types and
4//! callback tables; those don't unify cleanly. What unifies is the
5//! "boring" boundary glue: building `CStrings` from `ParamInfo`
6//! fields, picking the default bus layout, and resolving install-time
7//! name overrides.
8//!
9//! Each helper is a single small function so the wrappers stay
10//! greppable - the per-format vtable construction code reads as
11//! "for each param, get cstrings, build descriptor" without inlined
12//! `CString::new(...).unwrap_or_default()` boilerplate.
13//!
14//! Adding a new format wrapper? Reach for these first; only fall back
15//! to direct `CString::new` etc. when the format genuinely needs
16//! something none of the other formats does.
17
18use std::any::type_name;
19use std::ffi::CString;
20use std::panic::{AssertUnwindSafe, catch_unwind};
21use std::sync::Arc;
22
23use truce_params::ParamInfo;
24
25use crate::bus::BusLayout;
26use crate::export::PluginExport;
27
28pub use plugin_cell::{PluginCell, PluginGuard};
29
30/// The ownership cell every format wrapper puts around its plugin
31/// instance. The audio thread owns the plugin while the host is
32/// processing (`process`, the queued state apply); the host thread owns
33/// it while processing is stopped (`init`, `reset`, an inactive state
34/// load). The host contract makes those two mutually exclusive in time -
35/// a spec-compliant host never overlaps `process` with a lifecycle
36/// callback - so [`PluginCell`] holds no OS lock and the audio thread
37/// never waits. Ownership handoff carries a release-acquire edge (each
38/// owner observes the previous owner's writes), not mutual exclusion.
39///
40/// A host state save no longer touches the plugin at all: it reads the
41/// lock-free [`SnapshotSlot`](crate::snapshot::SnapshotSlot) the audio
42/// thread publishes each block (see [`save_extra`]). Meters ride the
43/// lock-free `MeterStore`, and params are atomic. So nothing on a
44/// non-audio thread contends with `process` on the hot path.
45///
46/// The soundness rests on the host's process/lifecycle exclusion
47/// contract; a debug-build overlap detector trips if a host ever
48/// violates it. The `Arc` is what makes GUI closures sound: they clone
49/// the handle instead of stashing a raw pointer into the instance
50/// struct.
51pub type SharedPlugin<P> = Arc<PluginCell<P>>;
52
53/// Wrap a freshly created plugin in the wrapper-standard ownership
54/// cell. See [`SharedPlugin`].
55pub fn shared_plugin<P>(plugin: P) -> SharedPlugin<P> {
56    Arc::new(PluginCell::new(plugin))
57}
58
59/// Take ownership of the plugin for the current callback. Never blocks:
60/// the audio thread owns the plugin while active, the host thread while
61/// inactive, and the host contract keeps the two from overlapping, so
62/// there is nothing to wait on. The returned guard's `&mut` is exclusive
63/// by that contract; the `Acquire` inside observes the previous owner's
64/// writes.
65pub fn enter_plugin<P>(plugin: &PluginCell<P>) -> PluginGuard<'_, P> {
66    plugin.enter()
67}
68
69/// Read the plugin's custom-state blob for a host state save.
70///
71/// Reads the lock-free [`SnapshotSlot`](crate::snapshot::SnapshotSlot)
72/// the audio thread publishes each block, and never takes the plugin
73/// lock: a host save must not stall the audio thread, which holds that
74/// lock for the whole block. A plugin with custom state publishes it
75/// through `snapshot_into`; one that publishes nothing has no custom
76/// state to save, so an empty blob is correct. The wrapper republishes
77/// the snapshot after any state change applied outside `process` (an
78/// inactive load), so an inactive save still returns live state. Params
79/// are serialized separately (also lock-free).
80#[must_use]
81pub fn save_extra(snapshot: &crate::snapshot::SnapshotSlot) -> Vec<u8> {
82    snapshot.read().unwrap_or_default()
83}
84
85/// Lock-free plugin ownership cell, uniform across platforms. Holds no
86/// OS mutex: the audio thread owns the plugin while processing, the host
87/// thread while stopped, and a spec-compliant host never overlaps the
88/// two. Ownership handoff is a release-acquire edge, not a lock, so
89/// `enter` never blocks and there is no poison, no priority inversion,
90/// and no per-platform variant.
91mod plugin_cell {
92    use std::cell::UnsafeCell;
93    use std::marker::PhantomData;
94    use std::ops::{Deref, DerefMut};
95    use std::sync::atomic::{AtomicU64, Ordering};
96
97    pub struct PluginCell<T> {
98        data: UnsafeCell<T>,
99        /// Release-acquire handoff counter. Each owner `Acquire`s on
100        /// entry (observing the previous owner's writes) and `Release`s
101        /// on exit (publishing its own), carrying the happens-before edge
102        /// between the audio thread and the host thread. Mutual exclusion
103        /// comes from the host contract - `process` never overlaps a
104        /// lifecycle callback - not from this counter.
105        handoff: AtomicU64,
106        /// Debug-only overlap detector: trips if two owners ever hold the
107        /// cell at once (a host contract violation). Compiled out in
108        /// release, where the contract is trusted.
109        #[cfg(debug_assertions)]
110        held: std::sync::atomic::AtomicBool,
111    }
112
113    // SAFETY: `T` is reached only through a guard, and the host contract
114    // hands it to one owner at a time - the same guarantee `Mutex<T>`
115    // leans on, so `Send`/`Sync` need only `T: Send`.
116    unsafe impl<T: Send> Send for PluginCell<T> {}
117    unsafe impl<T: Send> Sync for PluginCell<T> {}
118
119    impl<T> PluginCell<T> {
120        pub fn new(value: T) -> Self {
121            Self {
122                data: UnsafeCell::new(value),
123                handoff: AtomicU64::new(0),
124                #[cfg(debug_assertions)]
125                held: std::sync::atomic::AtomicBool::new(false),
126            }
127        }
128
129        /// Take ownership. Never blocks: the previous owner has already
130        /// released, by the host contract. The `Acquire` observes its
131        /// writes.
132        #[allow(
133            clippy::missing_panics_doc,
134            reason = "the only panic is the debug-only overlap detector, compiled out in release"
135        )]
136        pub fn enter(&self) -> PluginGuard<'_, T> {
137            self.handoff.load(Ordering::Acquire);
138            #[cfg(debug_assertions)]
139            assert!(
140                !self.held.swap(true, Ordering::Relaxed),
141                "plugin ownership cell entered while already held: the host \
142                 overlapped process() with a lifecycle callback"
143            );
144            PluginGuard {
145                cell: self,
146                _not_send: PhantomData,
147            }
148        }
149    }
150
151    /// Guard handing out the exclusive `&mut T`; releases the handoff on
152    /// drop so the next owner's `Acquire` sees this owner's writes.
153    pub struct PluginGuard<'a, T> {
154        cell: &'a PluginCell<T>,
155        /// The acquiring thread must also release, for the handoff edge
156        /// to mean anything - so the guard can't cross threads.
157        _not_send: PhantomData<*const ()>,
158    }
159
160    impl<T> Deref for PluginGuard<'_, T> {
161        type Target = T;
162        fn deref(&self) -> &T {
163            // SAFETY: this thread solely owns the cell for the guard's
164            // lifetime (host exclusion contract), so no other reference
165            // to `data` exists.
166            unsafe { &*self.cell.data.get() }
167        }
168    }
169
170    impl<T> DerefMut for PluginGuard<'_, T> {
171        fn deref_mut(&mut self) -> &mut T {
172            // SAFETY: as in `deref` - sole owner, so this `&mut` is unique.
173            unsafe { &mut *self.cell.data.get() }
174        }
175    }
176
177    impl<T> Drop for PluginGuard<'_, T> {
178        fn drop(&mut self) {
179            #[cfg(debug_assertions)]
180            self.cell.held.store(false, Ordering::Relaxed);
181            // Release: publish this owner's writes to the next `Acquire`.
182            self.cell.handoff.fetch_add(1, Ordering::Release);
183        }
184    }
185}
186
187/// `CStrings` derived from a single `ParamInfo`. All four conversions
188/// follow the same pattern (`unwrap_or_default()` so a `\0` in metadata
189/// degrades to an empty C string instead of panicking the host); pulling
190/// them into one struct keeps the per-format vtable loops uniform.
191pub struct ParamCStrings {
192    pub name: CString,
193    pub short_name: CString,
194    pub unit: CString,
195    pub group: CString,
196}
197
198impl ParamCStrings {
199    /// Build all four `CStrings` for one parameter.
200    #[must_use]
201    pub fn from_info(info: &ParamInfo) -> Self {
202        Self {
203            name: CString::new(info.name).unwrap_or_default(),
204            short_name: CString::new(info.short_name).unwrap_or_default(),
205            unit: CString::new(info.unit.as_str()).unwrap_or_default(),
206            group: CString::new(info.group).unwrap_or_default(),
207        }
208    }
209}
210
211/// `(input_channels, output_channels)` for the plugin's default bus
212/// layout, or `None` when the plugin declares no layouts.
213/// Used by every format's vtable / descriptor to advertise channel
214/// counts at registration time.
215///
216/// **Note for `aumi` (MIDI processor) plugins:** the convention is
217/// `bus_layouts: [BusLayout::new()]`, which has zero input *and* zero
218/// output channels. This helper returns `Some((0, 0))` for that case,
219/// which is correct for AU (the AU shim's `channelCapabilities`
220/// returns `[0, 0]` and the host treats the plugin as MIDI-only) but
221/// **wrong for AAX**, which requires every plugin to advertise at
222/// least stereo audio I/O. AAX maps `(0, 0)` to `(2, 2)` (synthesizing
223/// a stereo passthrough) after this helper returns. Don't push that
224/// remap into this helper; only AAX needs it.
225///
226/// `None` indicates a plugin-author bug: zero-bus plugins must return
227/// `vec![BusLayout::new()]` explicitly. Callers should log a
228/// diagnostic and skip registration (see how each `register_*` entry
229/// point handles this) rather than substitute a silent default that
230/// would misreport channel counts to the host.
231#[must_use]
232pub fn default_io_channels<P: PluginExport>() -> Option<(u32, u32)> {
233    P::bus_layouts()
234        .first()
235        .map(|l| (l.total_input_channels(), l.total_output_channels()))
236}
237
238/// `(max_input_channels, max_output_channels)` across every declared bus
239/// layout, or `None` when the plugin declares no layouts. Wrappers that
240/// let the host switch layouts at runtime (AU's per-instance stream
241/// format) size their process-time scratch to this so a later, wider
242/// layout selection doesn't outgrow buffers allocated for the first one.
243#[must_use]
244pub fn max_io_channels<P: PluginExport>() -> Option<(u32, u32)> {
245    P::bus_layouts().iter().fold(None, |acc, l| {
246        let (in_, out) = (l.total_input_channels(), l.total_output_channels());
247        Some(acc.map_or((in_, out), |(ai, ao): (u32, u32)| {
248            (ai.max(in_), ao.max(out))
249        }))
250    })
251}
252
253/// Pick the plugin's first bus layout, or `None` when the plugin
254/// declares no layouts.
255/// Used by wrappers (AAX, VST2) that need to read the layout *before*
256/// host-side bus-config negotiation, where a missing layout would
257/// otherwise produce silently-misreported channel counts.
258///
259/// For `aumi` plugins the returned layout is typically `BusLayout::new()`
260/// (zero in / zero out). AAX synthesizes `(2, 2)` from that case in
261/// `register_aax`; see [`default_io_channels`] for the rationale.
262///
263/// `None` is the same plugin-author-bug indicator as
264/// [`default_io_channels`]: log a diagnostic and skip registration.
265#[must_use]
266pub fn first_bus_layout<P: PluginExport>() -> Option<BusLayout> {
267    P::bus_layouts().into_iter().next()
268}
269
270/// Find the `bus_layouts()` index whose total input/output channel counts
271/// match `(inputs, outputs)`. Wrappers that negotiate a layout from a
272/// host-proposed arrangement (VST3 `setBusArrangements`, AU channel-config
273/// selection, the standalone device match, VST2's fixed I/O at load) use
274/// this to map a request onto a supported layout. `None` when nothing
275/// matches; the caller then rejects the arrangement or falls back to the
276/// first layout.
277#[must_use]
278pub fn find_bus_layout<P: PluginExport>(inputs: u32, outputs: u32) -> Option<usize> {
279    P::bus_layouts()
280        .iter()
281        .position(|l| l.total_input_channels() == inputs && l.total_output_channels() == outputs)
282}
283
284/// Standard diagnostic emitted by `register_*` when [`first_bus_layout`]
285/// or [`default_io_channels`] returns `None`. Centralised so every
286/// wrapper prints the same actionable message.
287pub fn log_missing_bus_layout<P: PluginExport>(format: &str) {
288    eprintln!(
289        "[truce {format}] {}::bus_layouts() returned an empty list - \
290         plugin will not register. Plugins with no audio I/O (e.g. \
291         aumi MIDI-effects) should return vec![BusLayout::new()] \
292         explicitly.",
293        type_name::<P>(),
294    );
295}
296
297/// Diagnostic for a plugin that declared more MIDI ports than the
298/// format can carry. The wrapper clamps to a single port and routes
299/// all traffic to port `0`; without this line the truncation would read
300/// as "multi-port supported." `declared` is the plugin's per-direction
301/// port count; nothing is logged for the single-port (or zero-port)
302/// case. `direction` is `"input"` / `"output"`.
303pub fn log_midi_ports_clamped(format: &str, direction: &str, declared: u8) {
304    if declared > 1 {
305        eprintln!(
306            "[truce {format}] plugin declares {declared} MIDI {direction} ports, but {format} \
307             carries one - routing all {direction} MIDI to port 0.",
308        );
309    }
310}
311
312/// Run a `register_*` body under [`std::panic::catch_unwind`].
313///
314/// Format wrappers' `register_*` entry points run during plugin
315/// registration - some from `extern "C" fn init` static
316/// initializers (`.init_array` / `__mod_init_func` / `.CRT$XCU`),
317/// others lazily on the first host query (AAX, to keep the Windows
318/// loader-lock window empty during Pro Tools' scan). A panic that
319/// escapes them crosses an `extern "C"`
320/// boundary and aborts the host process - a `panic = "abort"`
321/// configuration would do the same. Catching the unwind here turns
322/// any panic during registration into a logged diagnostic plus
323/// "host sees no plugin," which is the same outcome a plugin author
324/// would expect from a missing `bus_layouts` declaration.
325///
326/// `AssertUnwindSafe` is applied internally - the panic is treated
327/// as fatal-for-this-plugin, so leaving an `Arc` ref-count or
328/// `OnceLock` half-set is acceptable: the host won't load the
329/// plugin and the process will exit shortly after registration
330/// finishes anyway.
331pub fn run_register<P>(format: &str, body: impl FnOnce()) {
332    let result = catch_unwind(AssertUnwindSafe(body));
333    if let Err(payload) = result {
334        eprintln!(
335            "[truce {format}] panic during register for {}: {}",
336            type_name::<P>(),
337            extract_panic_msg(&payload),
338        );
339    }
340}
341
342/// Run a per-block audio-thread `body` under
343/// [`std::panic::catch_unwind`].
344///
345/// Format wrappers call this around the `cb_process` body so a panic
346/// from user `process()` can't unwind across the `extern "C"` FFI
347/// boundary into the host (UB on most toolchains; abort on others).
348/// Returns `true` on clean exit, `false` if the body panicked - the
349/// caller should zero output buffers on `false` so the host doesn't
350/// keep playing whatever happened to be in those slots.
351///
352/// Panic logging is one short `eprintln!` per occurrence; the audio
353/// thread should never panic, so the I/O is rare and acceptable.
354#[must_use]
355pub fn run_audio_block<P>(format: &str, body: impl FnOnce()) -> bool {
356    let result = catch_unwind(AssertUnwindSafe(body));
357    if let Err(payload) = result {
358        eprintln!(
359            "[truce {format}] panic in process() for {}: {}",
360            type_name::<P>(),
361            extract_panic_msg(&payload),
362        );
363        return false;
364    }
365    true
366}
367
368/// Like [`run_audio_block`] but for callbacks that return a status
369/// code. Returns `body`'s value on a clean exit, `fallback` if the
370/// body panicked. Used by the CLAP wrapper, whose process callback
371/// returns a `clap_process_status` `i32`.
372pub fn run_audio_block_with<P, R>(format: &str, fallback: R, body: impl FnOnce() -> R) -> R {
373    match catch_unwind(AssertUnwindSafe(body)) {
374        Ok(r) => r,
375        Err(payload) => {
376            eprintln!(
377                "[truce {format}] panic in process() for {}: {}",
378                type_name::<P>(),
379                extract_panic_msg(&payload),
380            );
381            fallback
382        }
383    }
384}
385
386/// Run a generic `extern "C"` callback body under
387/// [`std::panic::catch_unwind`]. Returns `body`'s value on a clean
388/// exit, `fallback` if the body panicked.
389///
390/// Same shape as [`run_audio_block_with`] but parameterized on
391/// `action` (e.g. `"save_state"`, `"load_state"`) so the panic log
392/// pinpoints which callback boundary fired. Use this for non-process
393/// FFI surfaces - state save / load, param formatting, anything the
394/// host calls through an `extern "C" fn` where a panic would unwind
395/// across an ABI that doesn't promise abort-on-unwind.
396///
397/// Audio-thread process bodies should keep using
398/// [`run_audio_block`] / [`run_audio_block_with`] - the hardcoded
399/// `"process()"` label there keeps existing log lines stable.
400pub fn run_extern_callback_with<P, R>(
401    format: &str,
402    action: &str,
403    fallback: R,
404    body: impl FnOnce() -> R,
405) -> R {
406    match catch_unwind(AssertUnwindSafe(body)) {
407        Ok(r) => r,
408        Err(payload) => {
409            eprintln!(
410                "[truce {format}] panic in {action} for {}: {}",
411                type_name::<P>(),
412                extract_panic_msg(&payload),
413            );
414            fallback
415        }
416    }
417}
418
419fn extract_panic_msg(payload: &Box<dyn std::any::Any + Send>) -> &str {
420    if let Some(s) = payload.downcast_ref::<&'static str>() {
421        s
422    } else if let Some(s) = payload.downcast_ref::<String>() {
423        s.as_str()
424    } else {
425        "<non-string panic payload>"
426    }
427}
428
429#[cfg(test)]
430mod plugin_cell_tests {
431    use std::sync::Arc;
432
433    use super::{enter_plugin, shared_plugin};
434
435    #[test]
436    fn lock_round_trips_data() {
437        let plugin = shared_plugin(41);
438        *enter_plugin(&plugin) += 1;
439        assert_eq!(*enter_plugin(&plugin), 42);
440    }
441
442    #[test]
443    fn repeated_ownership_publishes_writes() {
444        // Models the audio thread owning the cell block after block:
445        // each release-acquire cycle observes the previous cycle's write.
446        let plugin = shared_plugin(0u64);
447        for _ in 0..1000 {
448            *enter_plugin(&plugin) += 1;
449        }
450        assert_eq!(*enter_plugin(&plugin), 1000);
451    }
452
453    #[test]
454    fn handoff_carries_writes_across_a_thread() {
455        // A non-overlapping handoff (the host contract): the worker owns
456        // the cell, writes, and releases; only after it joins does the
457        // main thread acquire. The cell's `Acquire` makes the worker's
458        // write visible - no overlap, so the detector never trips.
459        let plugin = shared_plugin(0u32);
460        let worker = {
461            let plugin = Arc::clone(&plugin);
462            std::thread::spawn(move || {
463                *enter_plugin(&plugin) = 99;
464            })
465        };
466        worker.join().unwrap();
467        assert_eq!(*enter_plugin(&plugin), 99);
468    }
469
470    #[test]
471    fn panicking_owner_does_not_wedge_the_cell() {
472        // A panic in an owner unwinds through the guard's `Drop`, which
473        // releases the handoff (and clears the debug overlap flag), so
474        // the cell stays usable - one bad block can't wedge it.
475        let plugin = shared_plugin(7);
476        let for_panic = Arc::clone(&plugin);
477        let _ = std::thread::spawn(move || {
478            let _guard = enter_plugin(&for_panic);
479            panic!("wedge attempt");
480        })
481        .join();
482        assert_eq!(*enter_plugin(&plugin), 7);
483    }
484}