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