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_mutex::{PluginGuard, PluginMutex};
29
30/// The mediation lock every format wrapper puts around its plugin
31/// instance. The audio thread holds the lock for the duration of a
32/// block (`process`, `reset`, the queued state apply); host-thread
33/// state callbacks and the editor's `get_state` closure block for
34/// the read - safe in that direction, and bounded by the block the
35/// audio thread is finishing. Meters ride the lock-free `MeterStore`
36/// instead, so per-frame GUI paints never touch this lock.
37///
38/// The lock is only as strong as the scheduler behind it, so
39/// [`PluginMutex`] picks per platform:
40/// - **macOS**: `std::sync::Mutex` sits on `os_unfair_lock`, which
41///   donates the waiter's priority to the owner - a GUI thread
42///   preempted mid-`save_state` gets boosted to the waiting audio
43///   thread's priority.
44/// - **Linux**: a `PTHREAD_PRIO_INHERIT` pthread mutex; std's
45///   futex-based lock has no priority inheritance there.
46/// - **Windows**: `std::sync::Mutex` (SRWLOCK). User space has no
47///   priority-inheriting primitive, so the defense is the short
48///   hold - non-audio holders only span `save_state` / `editor()`.
49///
50/// (`parking_lot` inherits priority nowhere, which is why it isn't
51/// used. Uncontended cost is a CAS on every platform.)
52/// A panic while holding the guard unlocks on unwind; std's poison
53/// is forgiven (see [`lock_plugin`]).
54///
55/// A `Mutex` rather than an `RwLock`: the only non-audio accessors
56/// are a host state save and an editor preset capture - both rare -
57/// so reader parallelism buys nothing, and `Mutex<P>: Sync` needs
58/// only `P: Send` (an `RwLock` would force `Sync` onto every plugin
59/// type).
60///
61/// The `Arc` is what makes GUI closures sound: they clone the handle
62/// instead of stashing a raw pointer into the instance struct (whose
63/// `&mut` the audio thread holds during callbacks).
64pub type SharedPlugin<P> = Arc<PluginMutex<P>>;
65
66/// Wrap a freshly created plugin in the wrapper-standard mediation
67/// lock. See [`SharedPlugin`].
68pub fn shared_plugin<P>(plugin: P) -> SharedPlugin<P> {
69    let shared = Arc::new(PluginMutex::new(plugin));
70    // Warm the lock off the audio thread: the std-backed variant (macOS
71    // boxes a `pthread_mutex_t`) lazily allocates the OS mutex on first
72    // lock, which would otherwise land on the first audio callback that
73    // takes the mediation lock. Locking here forces that one-time init at
74    // creation. No-op on the Linux pthread variant (its mutex is built in
75    // `new`) and on Windows (SRWLOCK is inline).
76    drop(shared.lock());
77    shared
78}
79
80/// Lock the mediation lock, forgiving poison. A poisoned lock means a
81/// panic already escaped somewhere and was reported by the wrapper's
82/// panic guard; refusing every later block would turn one bad block
83/// into permanent silence, and the plugin's state is no more suspect
84/// than after any other caught panic. (The Linux pthread lock has no
85/// poison to forgive; unwind simply unlocks.)
86pub fn lock_plugin<P>(plugin: &PluginMutex<P>) -> PluginGuard<'_, P> {
87    plugin.lock()
88}
89
90/// [`lock_plugin`]'s non-blocking twin: `None` only when the lock is
91/// genuinely held (poison is forgiven, same rationale).
92pub fn try_lock_plugin<P>(plugin: &PluginMutex<P>) -> Option<PluginGuard<'_, P>> {
93    plugin.try_lock()
94}
95
96/// Read the plugin's custom-state blob for a host state save.
97///
98/// Prefers the lock-free [`SnapshotSlot`](crate::snapshot::SnapshotSlot)
99/// the audio thread publishes each block; only when the plugin doesn't
100/// opt into snapshots (nothing published) does it fall back to locking
101/// the plugin and calling `save_state()`. So a snapshot-capable plugin's
102/// host save never touches the plugin lock and never waits on an
103/// in-flight audio block. Params are serialized separately (lock-free),
104/// so this is the only piece of a save that could contend the lock.
105pub fn save_extra<P: crate::plugin::PluginRuntime>(
106    snapshot: &crate::snapshot::SnapshotSlot,
107    plugin: &PluginMutex<P>,
108) -> Vec<u8> {
109    snapshot
110        .read()
111        .unwrap_or_else(|| lock_plugin(plugin).save_state())
112}
113
114/// std-backed [`PluginMutex`]: macOS (`os_unfair_lock` donates the
115/// waiter's priority) and Windows (SRWLOCK; no user-space priority
116/// inheritance exists). Miri also lands here - it has no shim for
117/// `pthread_mutexattr_setprotocol`, and the std lock gives it full
118/// visibility.
119#[cfg(any(not(target_os = "linux"), miri))]
120mod plugin_mutex {
121    use std::ops::{Deref, DerefMut};
122    use std::sync::{Mutex, MutexGuard, PoisonError, TryLockError};
123
124    /// See [`super::SharedPlugin`] for the per-platform lock choice.
125    pub struct PluginMutex<T>(Mutex<T>);
126
127    /// Guard handing out the exclusive `&mut T`; unlocks on drop.
128    pub struct PluginGuard<'a, T>(MutexGuard<'a, T>);
129
130    impl<T> PluginMutex<T> {
131        pub fn new(value: T) -> Self {
132            Self(Mutex::new(value))
133        }
134
135        /// Block until the lock is held. Poison is forgiven (see
136        /// [`super::lock_plugin`]).
137        pub fn lock(&self) -> PluginGuard<'_, T> {
138            PluginGuard(self.0.lock().unwrap_or_else(PoisonError::into_inner))
139        }
140
141        /// `None` only when the lock is genuinely held.
142        pub fn try_lock(&self) -> Option<PluginGuard<'_, T>> {
143            match self.0.try_lock() {
144                Ok(guard) => Some(PluginGuard(guard)),
145                Err(TryLockError::Poisoned(poisoned)) => Some(PluginGuard(poisoned.into_inner())),
146                Err(TryLockError::WouldBlock) => None,
147            }
148        }
149    }
150
151    impl<T> Deref for PluginGuard<'_, T> {
152        type Target = T;
153        fn deref(&self) -> &T {
154            &self.0
155        }
156    }
157
158    impl<T> DerefMut for PluginGuard<'_, T> {
159        fn deref_mut(&mut self) -> &mut T {
160            &mut self.0
161        }
162    }
163}
164
165/// Linux [`PluginMutex`]: a `PTHREAD_PRIO_INHERIT` pthread mutex.
166/// std's futex-based lock has no priority inheritance, so a
167/// low-priority GUI thread preempted mid-`save_state` would stall
168/// the audio thread at the scheduler's mercy; with PI the holder
169/// inherits the waiting audio thread's priority for the remainder
170/// of the hold.
171#[cfg(all(target_os = "linux", not(miri)))]
172mod plugin_mutex {
173    use std::cell::UnsafeCell;
174    use std::marker::PhantomData;
175    use std::ops::{Deref, DerefMut};
176
177    /// See [`super::SharedPlugin`] for the per-platform lock choice.
178    pub struct PluginMutex<T> {
179        /// Boxed: a pthread mutex must not move once initialized,
180        /// and `Arc::new(PluginMutex::new(..))` moves the struct.
181        raw: Box<UnsafeCell<libc::pthread_mutex_t>>,
182        data: UnsafeCell<T>,
183    }
184
185    // SAFETY: the pthread mutex serializes all access to `data`, so
186    // sharing the container across threads hands `T` to one thread
187    // at a time - the same bound (`T: Send`) std's `Mutex` requires.
188    unsafe impl<T: Send> Send for PluginMutex<T> {}
189    // SAFETY: as above - `&PluginMutex` only reaches `T` through the
190    // lock, so `Sync` needs only `T: Send`.
191    unsafe impl<T: Send> Sync for PluginMutex<T> {}
192
193    impl<T> PluginMutex<T> {
194        pub fn new(value: T) -> Self {
195            let raw = Box::new(UnsafeCell::new(libc::PTHREAD_MUTEX_INITIALIZER));
196            // SAFETY: `raw` is freshly allocated and unshared; the
197            // attr is initialized before use and destroyed after.
198            unsafe {
199                let mut attr: libc::pthread_mutexattr_t = std::mem::zeroed();
200                if libc::pthread_mutexattr_init(&raw mut attr) == 0 {
201                    // Best effort: a libc refusing PI still leaves a
202                    // valid default-protocol attr, and init below
203                    // yields an ordinary mutex.
204                    let _ = libc::pthread_mutexattr_setprotocol(
205                        &raw mut attr,
206                        libc::PTHREAD_PRIO_INHERIT,
207                    );
208                    let _ = libc::pthread_mutex_init(raw.get(), &raw const attr);
209                    let _ = libc::pthread_mutexattr_destroy(&raw mut attr);
210                }
211            }
212            Self {
213                raw,
214                data: UnsafeCell::new(value),
215            }
216        }
217
218        /// Block until the lock is held. A panicking previous holder
219        /// unlocked on unwind (guard drop); there is no poison state.
220        pub fn lock(&self) -> PluginGuard<'_, T> {
221            // SAFETY: the mutex was initialized in `new` and outlives
222            // the returned guard's borrow.
223            unsafe {
224                libc::pthread_mutex_lock(self.raw.get());
225            }
226            PluginGuard {
227                lock: self,
228                _not_send: PhantomData,
229            }
230        }
231
232        /// `None` when the lock is held elsewhere.
233        pub fn try_lock(&self) -> Option<PluginGuard<'_, T>> {
234            // SAFETY: as in `lock`.
235            (unsafe { libc::pthread_mutex_trylock(self.raw.get()) } == 0).then_some(PluginGuard {
236                lock: self,
237                _not_send: PhantomData,
238            })
239        }
240    }
241
242    impl<T> Drop for PluginMutex<T> {
243        fn drop(&mut self) {
244            // SAFETY: `&mut self` proves no guard is alive, so the
245            // mutex is unlocked and safe to destroy.
246            unsafe {
247                libc::pthread_mutex_destroy(self.raw.get());
248            }
249        }
250    }
251
252    /// Guard handing out the exclusive `&mut T`; unlocks on drop.
253    pub struct PluginGuard<'a, T> {
254        lock: &'a PluginMutex<T>,
255        /// PI mutexes must be unlocked by the locking thread; the
256        /// raw-pointer marker strips `Send` so the guard can't cross.
257        _not_send: PhantomData<*const ()>,
258    }
259
260    impl<T> Deref for PluginGuard<'_, T> {
261        type Target = T;
262        fn deref(&self) -> &T {
263            // SAFETY: this guard holds the lock.
264            unsafe { &*self.lock.data.get() }
265        }
266    }
267
268    impl<T> DerefMut for PluginGuard<'_, T> {
269        fn deref_mut(&mut self) -> &mut T {
270            // SAFETY: this guard holds the lock exclusively.
271            unsafe { &mut *self.lock.data.get() }
272        }
273    }
274
275    impl<T> Drop for PluginGuard<'_, T> {
276        fn drop(&mut self) {
277            // SAFETY: this guard holds the lock; unlock happens on
278            // the locking thread (the guard is `!Send`).
279            unsafe {
280                libc::pthread_mutex_unlock(self.lock.raw.get());
281            }
282        }
283    }
284}
285
286/// `CStrings` derived from a single `ParamInfo`. All four conversions
287/// follow the same pattern (`unwrap_or_default()` so a `\0` in metadata
288/// degrades to an empty C string instead of panicking the host); pulling
289/// them into one struct keeps the per-format vtable loops uniform.
290pub struct ParamCStrings {
291    pub name: CString,
292    pub short_name: CString,
293    pub unit: CString,
294    pub group: CString,
295}
296
297impl ParamCStrings {
298    /// Build all four `CStrings` for one parameter.
299    #[must_use]
300    pub fn from_info(info: &ParamInfo) -> Self {
301        Self {
302            name: CString::new(info.name).unwrap_or_default(),
303            short_name: CString::new(info.short_name).unwrap_or_default(),
304            unit: CString::new(info.unit.as_str()).unwrap_or_default(),
305            group: CString::new(info.group).unwrap_or_default(),
306        }
307    }
308}
309
310/// `(input_channels, output_channels)` for the plugin's default bus
311/// layout, or `None` when the plugin declares no layouts.
312/// Used by every format's vtable / descriptor to advertise channel
313/// counts at registration time.
314///
315/// **Note for `aumi` (MIDI processor) plugins:** the convention is
316/// `bus_layouts: [BusLayout::new()]`, which has zero input *and* zero
317/// output channels. This helper returns `Some((0, 0))` for that case,
318/// which is correct for AU (the AU shim's `channelCapabilities`
319/// returns `[0, 0]` and the host treats the plugin as MIDI-only) but
320/// **wrong for AAX**, which requires every plugin to advertise at
321/// least stereo audio I/O. AAX maps `(0, 0)` to `(2, 2)` (synthesizing
322/// a stereo passthrough) after this helper returns. Don't push that
323/// remap into this helper; only AAX needs it.
324///
325/// `None` indicates a plugin-author bug: zero-bus plugins must return
326/// `vec![BusLayout::new()]` explicitly. Callers should log a
327/// diagnostic and skip registration (see how each `register_*` entry
328/// point handles this) rather than substitute a silent default that
329/// would misreport channel counts to the host.
330#[must_use]
331pub fn default_io_channels<P: PluginExport>() -> Option<(u32, u32)> {
332    P::bus_layouts()
333        .first()
334        .map(|l| (l.total_input_channels(), l.total_output_channels()))
335}
336
337/// Pick the plugin's first bus layout, or `None` when the plugin
338/// declares no layouts.
339/// Used by wrappers (AAX, VST2) that need to read the layout *before*
340/// host-side bus-config negotiation, where a missing layout would
341/// otherwise produce silently-misreported channel counts.
342///
343/// For `aumi` plugins the returned layout is typically `BusLayout::new()`
344/// (zero in / zero out). AAX synthesizes `(2, 2)` from that case in
345/// `register_aax`; see [`default_io_channels`] for the rationale.
346///
347/// `None` is the same plugin-author-bug indicator as
348/// [`default_io_channels`]: log a diagnostic and skip registration.
349#[must_use]
350pub fn first_bus_layout<P: PluginExport>() -> Option<BusLayout> {
351    P::bus_layouts().into_iter().next()
352}
353
354/// Standard diagnostic emitted by `register_*` when [`first_bus_layout`]
355/// or [`default_io_channels`] returns `None`. Centralised so every
356/// wrapper prints the same actionable message.
357pub fn log_missing_bus_layout<P: PluginExport>(format: &str) {
358    eprintln!(
359        "[truce {format}] {}::bus_layouts() returned an empty list - \
360         plugin will not register. Plugins with no audio I/O (e.g. \
361         aumi MIDI-effects) should return vec![BusLayout::new()] \
362         explicitly.",
363        type_name::<P>(),
364    );
365}
366
367/// Diagnostic for a plugin that declared more MIDI ports than the
368/// format can carry. The wrapper clamps to a single port and routes
369/// all traffic to port `0`; without this line the truncation would read
370/// as "multi-port supported." `declared` is the plugin's per-direction
371/// port count; nothing is logged for the single-port (or zero-port)
372/// case. `direction` is `"input"` / `"output"`.
373pub fn log_midi_ports_clamped(format: &str, direction: &str, declared: u8) {
374    if declared > 1 {
375        eprintln!(
376            "[truce {format}] plugin declares {declared} MIDI {direction} ports, but {format} \
377             carries one - routing all {direction} MIDI to port 0.",
378        );
379    }
380}
381
382/// Run a `register_*` body under [`std::panic::catch_unwind`].
383///
384/// Format wrappers' `register_*` entry points run during plugin
385/// registration - some from `extern "C" fn init` static
386/// initializers (`.init_array` / `__mod_init_func` / `.CRT$XCU`),
387/// others lazily on the first host query (AAX, to keep the Windows
388/// loader-lock window empty during Pro Tools' scan). A panic that
389/// escapes them crosses an `extern "C"`
390/// boundary and aborts the host process - a `panic = "abort"`
391/// configuration would do the same. Catching the unwind here turns
392/// any panic during registration into a logged diagnostic plus
393/// "host sees no plugin," which is the same outcome a plugin author
394/// would expect from a missing `bus_layouts` declaration.
395///
396/// `AssertUnwindSafe` is applied internally - the panic is treated
397/// as fatal-for-this-plugin, so leaving an `Arc` ref-count or
398/// `OnceLock` half-set is acceptable: the host won't load the
399/// plugin and the process will exit shortly after registration
400/// finishes anyway.
401pub fn run_register<P>(format: &str, body: impl FnOnce()) {
402    let result = catch_unwind(AssertUnwindSafe(body));
403    if let Err(payload) = result {
404        eprintln!(
405            "[truce {format}] panic during register for {}: {}",
406            type_name::<P>(),
407            extract_panic_msg(&payload),
408        );
409    }
410}
411
412/// Run a per-block audio-thread `body` under
413/// [`std::panic::catch_unwind`].
414///
415/// Format wrappers call this around the `cb_process` body so a panic
416/// from user `process()` can't unwind across the `extern "C"` FFI
417/// boundary into the host (UB on most toolchains; abort on others).
418/// Returns `true` on clean exit, `false` if the body panicked - the
419/// caller should zero output buffers on `false` so the host doesn't
420/// keep playing whatever happened to be in those slots.
421///
422/// Panic logging is one short `eprintln!` per occurrence; the audio
423/// thread should never panic, so the I/O is rare and acceptable.
424#[must_use]
425pub fn run_audio_block<P>(format: &str, body: impl FnOnce()) -> bool {
426    let result = catch_unwind(AssertUnwindSafe(body));
427    if let Err(payload) = result {
428        eprintln!(
429            "[truce {format}] panic in process() for {}: {}",
430            type_name::<P>(),
431            extract_panic_msg(&payload),
432        );
433        return false;
434    }
435    true
436}
437
438/// Like [`run_audio_block`] but for callbacks that return a status
439/// code. Returns `body`'s value on a clean exit, `fallback` if the
440/// body panicked. Used by the CLAP wrapper, whose process callback
441/// returns a `clap_process_status` `i32`.
442pub fn run_audio_block_with<P, R>(format: &str, fallback: R, body: impl FnOnce() -> R) -> R {
443    match catch_unwind(AssertUnwindSafe(body)) {
444        Ok(r) => r,
445        Err(payload) => {
446            eprintln!(
447                "[truce {format}] panic in process() for {}: {}",
448                type_name::<P>(),
449                extract_panic_msg(&payload),
450            );
451            fallback
452        }
453    }
454}
455
456/// Run a generic `extern "C"` callback body under
457/// [`std::panic::catch_unwind`]. Returns `body`'s value on a clean
458/// exit, `fallback` if the body panicked.
459///
460/// Same shape as [`run_audio_block_with`] but parameterized on
461/// `action` (e.g. `"save_state"`, `"load_state"`) so the panic log
462/// pinpoints which callback boundary fired. Use this for non-process
463/// FFI surfaces - state save / load, param formatting, anything the
464/// host calls through an `extern "C" fn` where a panic would unwind
465/// across an ABI that doesn't promise abort-on-unwind.
466///
467/// Audio-thread process bodies should keep using
468/// [`run_audio_block`] / [`run_audio_block_with`] - the hardcoded
469/// `"process()"` label there keeps existing log lines stable.
470pub fn run_extern_callback_with<P, R>(
471    format: &str,
472    action: &str,
473    fallback: R,
474    body: impl FnOnce() -> R,
475) -> R {
476    match catch_unwind(AssertUnwindSafe(body)) {
477        Ok(r) => r,
478        Err(payload) => {
479            eprintln!(
480                "[truce {format}] panic in {action} for {}: {}",
481                type_name::<P>(),
482                extract_panic_msg(&payload),
483            );
484            fallback
485        }
486    }
487}
488
489fn extract_panic_msg(payload: &Box<dyn std::any::Any + Send>) -> &str {
490    if let Some(s) = payload.downcast_ref::<&'static str>() {
491        s
492    } else if let Some(s) = payload.downcast_ref::<String>() {
493        s.as_str()
494    } else {
495        "<non-string panic payload>"
496    }
497}
498
499#[cfg(test)]
500mod plugin_mutex_tests {
501    use std::sync::Arc;
502
503    use super::{lock_plugin, shared_plugin, try_lock_plugin};
504
505    #[test]
506    fn lock_round_trips_data() {
507        let plugin = shared_plugin(41);
508        *lock_plugin(&plugin) += 1;
509        assert_eq!(*lock_plugin(&plugin), 42);
510    }
511
512    #[test]
513    fn try_lock_reports_contention() {
514        let plugin = shared_plugin(0u32);
515        let held = lock_plugin(&plugin);
516        assert!(try_lock_plugin(&plugin).is_none());
517        drop(held);
518        assert!(try_lock_plugin(&plugin).is_some());
519    }
520
521    #[test]
522    fn excludes_across_threads() {
523        // Unsynchronized increments would lose updates; the final
524        // count proves the guard serializes every access.
525        let plugin = shared_plugin(0u64);
526        let threads: Vec<_> = (0..4)
527            .map(|_| {
528                let plugin = Arc::clone(&plugin);
529                std::thread::spawn(move || {
530                    for _ in 0..10_000 {
531                        *lock_plugin(&plugin) += 1;
532                    }
533                })
534            })
535            .collect();
536        for t in threads {
537            t.join().unwrap();
538        }
539        assert_eq!(*lock_plugin(&plugin), 40_000);
540    }
541
542    #[test]
543    fn panicking_holder_does_not_wedge_the_lock() {
544        // One bad block must not turn into permanent silence: a
545        // panicking holder unlocks on unwind (std poison forgiven,
546        // pthread unlocked by the guard drop).
547        let plugin = shared_plugin(7);
548        let for_panic = Arc::clone(&plugin);
549        let _ = std::thread::spawn(move || {
550            let _guard = lock_plugin(&for_panic);
551            panic!("wedge attempt");
552        })
553        .join();
554        assert_eq!(*lock_plugin(&plugin), 7);
555        assert!(try_lock_plugin(&plugin).is_some());
556    }
557}