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::os::raw::c_char;
21use std::panic::{AssertUnwindSafe, catch_unwind};
22use std::sync::Arc;
23
24use truce_params::ParamInfo;
25
26use crate::bus::BusLayout;
27use crate::export::PluginExport;
28
29pub use plugin_cell::{PluginCell, PluginGuard};
30
31/// The ownership cell the real-time format wrappers (CLAP, VST3, VST2,
32/// AU, AAX) put around their plugin instance. The audio thread owns the
33/// plugin while the host is processing (`process`, the queued state
34/// apply); the host thread owns it while processing is stopped (`init`,
35/// `reset`, an inactive state load). The host contract makes those two
36/// mutually exclusive in time - a spec-compliant host never overlaps
37/// `process` with a lifecycle callback - so [`PluginCell`] holds no OS
38/// lock and the audio thread never waits. Ownership handoff carries a
39/// release-acquire edge (each owner observes the previous owner's
40/// writes), not mutual exclusion.
41///
42/// LV2 is the exception: its `save`/`restore` run in the non-realtime
43/// instantiation thread class, which the host already serializes against
44/// `run`, so it owns its plugin directly and saves through
45/// `Plugin::save_state` (which still funnels to `snapshot_into`) rather
46/// than the snapshot slot. Nothing there contends with the audio thread,
47/// so the cell would buy it nothing.
48///
49/// A host state save no longer touches the plugin at all: it reads the
50/// lock-free [`SnapshotSlot`](crate::snapshot::SnapshotSlot) the audio
51/// thread publishes each block (see [`save_extra`]). Meters ride the
52/// lock-free `MeterStore`, and params are atomic. So nothing on a
53/// non-audio thread contends with `process` on the hot path.
54///
55/// The soundness rests on the host's process/lifecycle exclusion
56/// contract; a debug-build overlap detector trips if a host ever
57/// violates it. The `Arc` is what makes GUI closures sound: they clone
58/// the handle instead of stashing a raw pointer into the instance
59/// struct.
60pub type SharedPlugin<P> = Arc<PluginCell<P>>;
61
62/// Wrap a freshly created plugin in the wrapper-standard ownership
63/// cell. See [`SharedPlugin`].
64pub fn shared_plugin<P>(plugin: P) -> SharedPlugin<P> {
65    Arc::new(PluginCell::new(plugin))
66}
67
68/// Take ownership of the plugin for the current callback. Never blocks:
69/// the audio thread owns the plugin while active, the host thread while
70/// inactive, and the host contract keeps the two from overlapping, so
71/// there is nothing to wait on. The returned guard's `&mut` is exclusive
72/// by that contract; the `Acquire` inside observes the previous owner's
73/// writes.
74pub fn enter_plugin<P>(plugin: &PluginCell<P>) -> PluginGuard<'_, P> {
75    plugin.enter()
76}
77
78/// Read the plugin's custom-state blob for a host state save.
79///
80/// Reads the lock-free [`SnapshotSlot`](crate::snapshot::SnapshotSlot)
81/// the audio thread publishes each block, and never takes the plugin
82/// lock: a host save must not stall the audio thread, which holds that
83/// lock for the whole block. A plugin with custom state publishes it
84/// through `snapshot_into`; one that publishes nothing has no custom
85/// state to save, so an empty blob is correct. The wrapper republishes
86/// the snapshot after any state change applied outside `process` (an
87/// inactive load), so an inactive save still returns live state. Params
88/// are serialized separately (also lock-free).
89#[must_use]
90pub fn save_extra(snapshot: &crate::snapshot::SnapshotSlot) -> Vec<u8> {
91    snapshot.read().unwrap_or_default()
92}
93
94/// Lock-free plugin ownership cell, uniform across platforms. Holds no
95/// OS mutex: the audio thread owns the plugin while processing, the host
96/// thread while stopped, and a spec-compliant host never overlaps the
97/// two. Ownership handoff is a release-acquire edge, not a lock, so
98/// `enter` never blocks and there is no poison, no priority inversion,
99/// and no per-platform variant.
100mod plugin_cell {
101    use std::cell::UnsafeCell;
102    use std::marker::PhantomData;
103    use std::ops::{Deref, DerefMut};
104    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
105
106    pub struct PluginCell<T> {
107        data: UnsafeCell<T>,
108        /// Release-acquire handoff counter. Each owner `Acquire`s on
109        /// entry (observing the previous owner's writes) and `Release`s
110        /// on exit (publishing its own), carrying the happens-before edge
111        /// between the audio thread and the host thread. Mutual exclusion
112        /// comes from the host contract - `process` never overlaps a
113        /// lifecycle callback - not from this counter.
114        handoff: AtomicU64,
115        /// Whether the cell is currently held. Tracked in every build (not
116        /// just debug) so [`Self::try_enter`] can decline re-entry in
117        /// release rather than hand out a second aliasing `&mut`. `enter`
118        /// still trusts the host contract (it asserts free only in debug);
119        /// `try_enter` is the safe variant for paths where author code may
120        /// re-enter (e.g. `request_resize` called from inside `set_size`).
121        held: AtomicBool,
122        /// Token of the thread currently holding the cell, for an accurate
123        /// debug overlap message: same-thread re-entry (author code reached
124        /// back into the cell) reads differently than a cross-thread overlap
125        /// (a genuine host contract violation). Debug-only.
126        #[cfg(debug_assertions)]
127        owner: AtomicU64,
128    }
129
130    /// A never-zero per-thread token for the debug overlap detector. `0` is
131    /// reserved for "cell free", so the counter starts at 1.
132    #[cfg(debug_assertions)]
133    fn thread_token() -> u64 {
134        thread_local! {
135            static TOKEN: u64 = {
136                static NEXT: AtomicU64 = AtomicU64::new(1);
137                NEXT.fetch_add(1, Ordering::Relaxed)
138            };
139        }
140        TOKEN.with(|&t| t)
141    }
142
143    // SAFETY: `T` is reached only through a guard, and the host contract
144    // hands it to one owner at a time - the same guarantee `Mutex<T>`
145    // leans on, so `Send`/`Sync` need only `T: Send`.
146    unsafe impl<T: Send> Send for PluginCell<T> {}
147    unsafe impl<T: Send> Sync for PluginCell<T> {}
148
149    impl<T> PluginCell<T> {
150        pub fn new(value: T) -> Self {
151            Self {
152                data: UnsafeCell::new(value),
153                handoff: AtomicU64::new(0),
154                held: AtomicBool::new(false),
155                #[cfg(debug_assertions)]
156                owner: AtomicU64::new(0),
157            }
158        }
159
160        /// Take ownership. Never blocks: the previous owner has already
161        /// released, by the host contract. The `Acquire` observes its
162        /// writes.
163        #[allow(
164            clippy::missing_panics_doc,
165            reason = "the only panic is the debug-only overlap detector, compiled out in release"
166        )]
167        pub fn enter(&self) -> PluginGuard<'_, T> {
168            self.handoff.load(Ordering::Acquire);
169            let was_held = self.held.swap(true, Ordering::Relaxed);
170            #[cfg(debug_assertions)]
171            {
172                let me = thread_token();
173                let prev = self.owner.swap(me, Ordering::Relaxed);
174                assert!(
175                    !was_held,
176                    "{}",
177                    if prev == me {
178                        "plugin ownership cell re-entered on the same thread: \
179                         author editor code (e.g. request_resize called from \
180                         set_size / state_changed) reached back into the cell \
181                         while the wrapper still held it"
182                    } else {
183                        "plugin ownership cell entered from another thread while \
184                         held: the host overlapped process() with a lifecycle \
185                         callback"
186                    }
187                );
188            }
189            #[cfg(not(debug_assertions))]
190            let _ = was_held;
191            PluginGuard {
192                cell: self,
193                _not_send: PhantomData,
194            }
195        }
196
197        /// Take ownership only if the cell is free, returning `None` when it
198        /// is already held. Unlike [`Self::enter`] this never asserts and
199        /// never hands out a second `&mut` - it is the safe primitive for
200        /// paths where author code may re-enter the cell (a `request_resize`
201        /// closure invoked synchronously from within an `Editor` method the
202        /// wrapper called while holding the guard). Callers defer the work
203        /// (stash it and apply on the next entry) when they get `None`.
204        ///
205        /// # Invariant
206        /// `try_enter` only defends against *same-thread* re-entry. Release
207        /// [`Self::enter`] swaps `held` to `true` unconditionally and ignores
208        /// the prior value (it trusts the host contract), so an `enter` that
209        /// overlapped a `try_enter` on another thread would barge in past a
210        /// live guard with no signal. Every owner of a given cell must
211        /// therefore run on one logical thread of control: today the `audio`
212        /// cell uses only `enter` (audio thread, host-serialized) and the
213        /// `gui` cell uses `enter` + `try_enter` only on the host GUI thread.
214        /// Do not mix `enter` on one thread with `try_enter` on another for
215        /// the same cell.
216        pub fn try_enter(&self) -> Option<PluginGuard<'_, T>> {
217            self.handoff.load(Ordering::Acquire);
218            if self
219                .held
220                .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
221                .is_err()
222            {
223                return None;
224            }
225            #[cfg(debug_assertions)]
226            self.owner.store(thread_token(), Ordering::Relaxed);
227            Some(PluginGuard {
228                cell: self,
229                _not_send: PhantomData,
230            })
231        }
232    }
233
234    /// Guard handing out the exclusive `&mut T`; releases the handoff on
235    /// drop so the next owner's `Acquire` sees this owner's writes.
236    pub struct PluginGuard<'a, T> {
237        cell: &'a PluginCell<T>,
238        /// The acquiring thread must also release, for the handoff edge
239        /// to mean anything - so the guard can't cross threads.
240        _not_send: PhantomData<*const ()>,
241    }
242
243    impl<T> Deref for PluginGuard<'_, T> {
244        type Target = T;
245        fn deref(&self) -> &T {
246            // SAFETY: this thread solely owns the cell for the guard's
247            // lifetime (host exclusion contract), so no other reference
248            // to `data` exists.
249            unsafe { &*self.cell.data.get() }
250        }
251    }
252
253    impl<T> DerefMut for PluginGuard<'_, T> {
254        fn deref_mut(&mut self) -> &mut T {
255            // SAFETY: as in `deref` - sole owner, so this `&mut` is unique.
256            unsafe { &mut *self.cell.data.get() }
257        }
258    }
259
260    impl<T> Drop for PluginGuard<'_, T> {
261        fn drop(&mut self) {
262            #[cfg(debug_assertions)]
263            self.cell.owner.store(0, Ordering::Relaxed);
264            self.cell.held.store(false, Ordering::Relaxed);
265            // Release: publish this owner's writes to the next `Acquire`.
266            self.cell.handoff.fetch_add(1, Ordering::Release);
267        }
268    }
269}
270
271/// `CStrings` derived from a single `ParamInfo`. All four conversions
272/// follow the same pattern (`unwrap_or_default()` so a `\0` in metadata
273/// degrades to an empty C string instead of panicking the host); pulling
274/// them into one struct keeps the per-format vtable loops uniform.
275pub struct ParamCStrings {
276    pub name: CString,
277    pub short_name: CString,
278    pub unit: CString,
279    pub group: CString,
280}
281
282impl ParamCStrings {
283    /// Build all four `CStrings` for one parameter.
284    #[must_use]
285    pub fn from_info(info: &ParamInfo) -> Self {
286        Self {
287            name: CString::new(info.name).unwrap_or_default(),
288            short_name: CString::new(info.short_name).unwrap_or_default(),
289            unit: CString::new(info.unit.as_str()).unwrap_or_default(),
290            group: CString::new(info.group).unwrap_or_default(),
291        }
292    }
293}
294
295/// `(input_channels, output_channels)` for the plugin's default bus
296/// layout, or `None` when the plugin declares no layouts.
297/// Used by every format's vtable / descriptor to advertise channel
298/// counts at registration time.
299///
300/// **Note for `aumi` (MIDI processor) plugins:** the convention is
301/// `bus_layouts: [BusLayout::new()]`, which has zero input *and* zero
302/// output channels. This helper returns `Some((0, 0))` for that case,
303/// which is correct for AU (the AU shim's `channelCapabilities`
304/// returns `[0, 0]` and the host treats the plugin as MIDI-only) but
305/// **wrong for AAX**, which requires every plugin to advertise at
306/// least stereo audio I/O. AAX maps `(0, 0)` to `(2, 2)` (synthesizing
307/// a stereo passthrough) after this helper returns. Don't push that
308/// remap into this helper; only AAX needs it.
309///
310/// `None` indicates a plugin-author bug: zero-bus plugins must return
311/// `vec![BusLayout::new()]` explicitly. Callers should log a
312/// diagnostic and skip registration (see how each `register_*` entry
313/// point handles this) rather than substitute a silent default that
314/// would misreport channel counts to the host.
315#[must_use]
316pub fn default_io_channels<P: PluginExport>() -> Option<(u32, u32)> {
317    P::bus_layouts()
318        .first()
319        .map(|l| (l.total_input_channels(), l.total_output_channels()))
320}
321
322/// `(max_input_channels, max_output_channels)` across every declared bus
323/// layout, or `None` when the plugin declares no layouts. Wrappers that
324/// let the host switch layouts at runtime (AU's per-instance stream
325/// format) size their process-time scratch to this so a later, wider
326/// layout selection doesn't outgrow buffers allocated for the first one.
327#[must_use]
328pub fn max_io_channels<P: PluginExport>() -> Option<(u32, u32)> {
329    P::bus_layouts().iter().fold(None, |acc, l| {
330        let (in_, out) = (l.total_input_channels(), l.total_output_channels());
331        Some(acc.map_or((in_, out), |(ai, ao): (u32, u32)| {
332            (ai.max(in_), ao.max(out))
333        }))
334    })
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/// Find the `bus_layouts()` index whose total input/output channel counts
355/// match `(inputs, outputs)`. Wrappers that negotiate a layout from a
356/// host-proposed arrangement (VST3 `setBusArrangements`, AU channel-config
357/// selection, the standalone device match, VST2's fixed I/O at load) use
358/// this to map a request onto a supported layout. `None` when nothing
359/// matches; the caller then rejects the arrangement or falls back to the
360/// first layout.
361#[must_use]
362pub fn find_bus_layout<P: PluginExport>(inputs: u32, outputs: u32) -> Option<usize> {
363    P::bus_layouts()
364        .iter()
365        .position(|l| l.total_input_channels() == inputs && l.total_output_channels() == outputs)
366}
367
368/// Standard diagnostic emitted by `register_*` when [`first_bus_layout`]
369/// or [`default_io_channels`] returns `None`. Centralised so every
370/// wrapper prints the same actionable message.
371pub fn log_missing_bus_layout<P: PluginExport>(format: &str) {
372    eprintln!(
373        "[truce {format}] {}::bus_layouts() returned an empty list - \
374         plugin will not register. Plugins with no audio I/O (e.g. \
375         aumi MIDI-effects) should return vec![BusLayout::new()] \
376         explicitly.",
377        type_name::<P>(),
378    );
379}
380
381/// Diagnostic for a plugin that declared more MIDI ports than the
382/// format can carry. The wrapper clamps to a single port and routes
383/// all traffic to port `0`; without this line the truncation would read
384/// as "multi-port supported." `declared` is the plugin's per-direction
385/// port count; nothing is logged for the single-port (or zero-port)
386/// case. `direction` is `"input"` / `"output"`.
387pub fn log_midi_ports_clamped(format: &str, direction: &str, declared: u8) {
388    if declared > 1 {
389        eprintln!(
390            "[truce {format}] plugin declares {declared} MIDI {direction} ports, but {format} \
391             carries one - routing all {direction} MIDI to port 0.",
392        );
393    }
394}
395
396/// Run a `register_*` body under [`std::panic::catch_unwind`].
397///
398/// Format wrappers' `register_*` entry points run during plugin
399/// registration - some from `extern "C" fn init` static
400/// initializers (`.init_array` / `__mod_init_func` / `.CRT$XCU`),
401/// others lazily on the first host query (AAX, to keep the Windows
402/// loader-lock window empty during Pro Tools' scan). A panic that
403/// escapes them crosses an `extern "C"`
404/// boundary and aborts the host process - a `panic = "abort"`
405/// configuration would do the same. Catching the unwind here turns
406/// any panic during registration into a logged diagnostic plus
407/// "host sees no plugin," which is the same outcome a plugin author
408/// would expect from a missing `bus_layouts` declaration.
409///
410/// `AssertUnwindSafe` is applied internally - the panic is treated
411/// as fatal-for-this-plugin, so leaving an `Arc` ref-count or
412/// `OnceLock` half-set is acceptable: the host won't load the
413/// plugin and the process will exit shortly after registration
414/// finishes anyway.
415pub fn run_register<P>(format: &str, body: impl FnOnce()) {
416    let result = catch_unwind(AssertUnwindSafe(body));
417    if let Err(payload) = result {
418        eprintln!(
419            "[truce {format}] panic during register for {}: {}",
420            type_name::<P>(),
421            extract_panic_msg(&payload),
422        );
423    }
424}
425
426/// Run a per-block audio-thread `body` under
427/// [`std::panic::catch_unwind`].
428///
429/// Format wrappers call this around the `cb_process` body so a panic
430/// from user `process()` can't unwind across the `extern "C"` FFI
431/// boundary into the host (UB on most toolchains; abort on others).
432/// Returns `true` on clean exit, `false` if the body panicked - the
433/// caller should zero output buffers on `false` so the host doesn't
434/// keep playing whatever happened to be in those slots.
435///
436/// Panic logging is one short `eprintln!` per occurrence; the audio
437/// thread should never panic, so the I/O is rare and acceptable.
438#[must_use]
439pub fn run_audio_block<P>(format: &str, body: impl FnOnce()) -> bool {
440    let result = catch_unwind(AssertUnwindSafe(body));
441    if let Err(payload) = result {
442        eprintln!(
443            "[truce {format}] panic in process() for {}: {}",
444            type_name::<P>(),
445            extract_panic_msg(&payload),
446        );
447        return false;
448    }
449    true
450}
451
452/// Like [`run_audio_block`] but for callbacks that return a status
453/// code. Returns `body`'s value on a clean exit, `fallback` if the
454/// body panicked. Used by the CLAP wrapper, whose process callback
455/// returns a `clap_process_status` `i32`.
456pub fn run_audio_block_with<P, R>(format: &str, fallback: R, body: impl FnOnce() -> R) -> R {
457    match catch_unwind(AssertUnwindSafe(body)) {
458        Ok(r) => r,
459        Err(payload) => {
460            eprintln!(
461                "[truce {format}] panic in process() for {}: {}",
462                type_name::<P>(),
463                extract_panic_msg(&payload),
464            );
465            fallback
466        }
467    }
468}
469
470/// Run a generic `extern "C"` callback body under
471/// [`std::panic::catch_unwind`]. Returns `body`'s value on a clean
472/// exit, `fallback` if the body panicked.
473///
474/// Same shape as [`run_audio_block_with`] but parameterized on
475/// `action` (e.g. `"save_state"`, `"load_state"`) so the panic log
476/// pinpoints which callback boundary fired. Use this for non-process
477/// FFI surfaces - state save / load, param formatting, anything the
478/// host calls through an `extern "C" fn` where a panic would unwind
479/// across an ABI that doesn't promise abort-on-unwind.
480///
481/// Audio-thread process bodies should keep using
482/// [`run_audio_block`] / [`run_audio_block_with`] - the hardcoded
483/// `"process()"` label there keeps existing log lines stable.
484pub fn run_extern_callback_with<P, R>(
485    format: &str,
486    action: &str,
487    fallback: R,
488    body: impl FnOnce() -> R,
489) -> R {
490    match catch_unwind(AssertUnwindSafe(body)) {
491        Ok(r) => r,
492        Err(payload) => {
493            eprintln!(
494                "[truce {format}] panic in {action} for {}: {}",
495                type_name::<P>(),
496                extract_panic_msg(&payload),
497            );
498            fallback
499        }
500    }
501}
502
503fn extract_panic_msg(payload: &Box<dyn std::any::Any + Send>) -> &str {
504    if let Some(s) = payload.downcast_ref::<&'static str>() {
505        s
506    } else if let Some(s) = payload.downcast_ref::<String>() {
507        s.as_str()
508    } else {
509        "<non-string panic payload>"
510    }
511}
512
513/// Copy `text` into the host's C-string buffer `out` of capacity `out_len`
514/// bytes (including the trailing NUL), NUL-terminate, and return the number
515/// of content bytes written (excluding the NUL).
516///
517/// Truncates on a UTF-8 char boundary: audio param display strings are full
518/// of multi-byte characters (`°`, `µs`, `−12 dB`, `Δ`, `♯`), and cutting one
519/// mid-codepoint yields an invalid C string that strict host readers reject
520/// wholesale. Every format wrapper's `format_value` path funnels through
521/// here so the truncation rule lives in one place, not five copies.
522///
523/// # Safety
524/// `out` must be valid for writes of `out_len` bytes, and `out_len` must be
525/// `> 0`. Callers guard `out_len == 0` / a null `out` as "host wants
526/// nothing" before calling.
527#[must_use]
528pub unsafe fn copy_c_str(out: *mut c_char, out_len: usize, text: &str) -> usize {
529    let bytes = text.as_bytes();
530    let mut len = bytes.len().min(out_len - 1);
531    // Walk back off a torn multi-byte tail. `is_char_boundary(bytes.len())`
532    // is always true, so a string that fits untouched never loops.
533    while len > 0 && !text.is_char_boundary(len) {
534        len -= 1;
535    }
536    // SAFETY: `len < out_len` (so `out.add(len)` is in bounds for the NUL),
537    // and `out` is valid for `out_len` writes per the contract. Reinterpreting
538    // the `u8` bytes as `c_char` is a bit-preserving copy on every platform
539    // (`c_char` is `i8` or `u8`).
540    unsafe {
541        std::ptr::copy_nonoverlapping(bytes.as_ptr().cast::<c_char>(), out, len);
542        *out.add(len) = 0;
543    }
544    len
545}
546
547#[cfg(test)]
548mod plugin_cell_tests {
549    use std::sync::Arc;
550
551    use super::{PluginCell, enter_plugin, shared_plugin};
552
553    #[test]
554    fn try_enter_declines_while_held_then_accepts_when_free() {
555        // The request_resize re-entry guard: `try_enter` must return `None`
556        // while the cell is held (so the closure defers instead of aliasing)
557        // and `Some` once released.
558        let cell = PluginCell::new(0u32);
559        let guard = cell.enter();
560        assert!(cell.try_enter().is_none(), "held cell declines try_enter");
561        drop(guard);
562        assert!(cell.try_enter().is_some(), "freed cell accepts try_enter");
563    }
564
565    #[test]
566    #[cfg(debug_assertions)]
567    #[should_panic(expected = "re-entered on the same thread")]
568    fn enter_while_held_reports_same_thread_reentry() {
569        // Author editor code calling back into the cell (request_resize from
570        // set_size) must be blamed accurately, not reported as a host overlap.
571        let cell = PluginCell::new(0u32);
572        let _held = cell.enter();
573        let _reenter = cell.enter();
574    }
575
576    #[test]
577    fn lock_round_trips_data() {
578        let plugin = shared_plugin(41);
579        *enter_plugin(&plugin) += 1;
580        assert_eq!(*enter_plugin(&plugin), 42);
581    }
582
583    #[test]
584    fn repeated_ownership_publishes_writes() {
585        // Models the audio thread owning the cell block after block:
586        // each release-acquire cycle observes the previous cycle's write.
587        let plugin = shared_plugin(0u64);
588        for _ in 0..1000 {
589            *enter_plugin(&plugin) += 1;
590        }
591        assert_eq!(*enter_plugin(&plugin), 1000);
592    }
593
594    #[test]
595    fn handoff_carries_writes_across_a_thread() {
596        // A non-overlapping handoff (the host contract): the worker owns
597        // the cell, writes, and releases; only after it joins does the
598        // main thread acquire. The cell's `Acquire` makes the worker's
599        // write visible - no overlap, so the detector never trips.
600        let plugin = shared_plugin(0u32);
601        let worker = {
602            let plugin = Arc::clone(&plugin);
603            std::thread::spawn(move || {
604                *enter_plugin(&plugin) = 99;
605            })
606        };
607        worker.join().unwrap();
608        assert_eq!(*enter_plugin(&plugin), 99);
609    }
610
611    #[test]
612    fn panicking_owner_does_not_wedge_the_cell() {
613        // A panic in an owner unwinds through the guard's `Drop`, which
614        // releases the handoff (and clears the debug overlap flag), so
615        // the cell stays usable - one bad block can't wedge it.
616        let plugin = shared_plugin(7);
617        let for_panic = Arc::clone(&plugin);
618        let _ = std::thread::spawn(move || {
619            let _guard = enter_plugin(&for_panic);
620            panic!("wedge attempt");
621        })
622        .join();
623        assert_eq!(*enter_plugin(&plugin), 7);
624    }
625}
626
627#[cfg(test)]
628mod copy_c_str_tests {
629    use super::copy_c_str;
630    use std::os::raw::c_char;
631
632    /// Copy `text` into a `cap`-byte buffer; return `(written_len, content)`
633    /// where `content` is the NUL-terminated bytes read back as a `String`.
634    fn run(text: &str, cap: usize) -> (usize, String) {
635        let mut buf = vec![0 as c_char; cap];
636        // SAFETY: `buf` is `cap` `c_char`, and `cap > 0` in every case here.
637        let n = unsafe { copy_c_str(buf.as_mut_ptr(), cap, text) };
638        // `c_char` -> `u8` is the bit-preserving inverse of the copy.
639        #[allow(clippy::cast_sign_loss)]
640        let bytes: Vec<u8> = buf[..n].iter().map(|&c| c as u8).collect();
641        (n, String::from_utf8(bytes).unwrap())
642    }
643
644    #[test]
645    fn copies_when_it_fits() {
646        let (n, s) = run("-6 dB", 32);
647        assert_eq!(n, 5);
648        assert_eq!(s, "-6 dB");
649    }
650
651    #[test]
652    fn writes_the_trailing_nul() {
653        let mut buf = [1 as c_char; 8];
654        // SAFETY: 8-byte buffer, capacity 8.
655        let n = unsafe { copy_c_str(buf.as_mut_ptr(), 8, "ab") };
656        assert_eq!(n, 2);
657        assert_eq!(buf[2], 0, "NUL terminator written after the content");
658    }
659
660    #[test]
661    fn truncates_ascii_to_capacity() {
662        // cap 4 -> 3 content bytes + NUL.
663        let (n, s) = run("abcdef", 4);
664        assert_eq!(n, 3);
665        assert_eq!(s, "abc");
666    }
667
668    #[test]
669    fn truncates_on_a_char_boundary() {
670        // "12°" is [0x31, 0x32, 0xC2, 0xB0]. cap 4 -> 3 content bytes would
671        // cut '°' (U+00B0) mid-codepoint; the guard backs off to 2 so the
672        // result is valid UTF-8, not a torn tail a strict host rejects.
673        let (n, s) = run("12°", 4);
674        assert_eq!(n, 2, "dropped the half-written degree sign");
675        assert_eq!(s, "12");
676    }
677
678    #[test]
679    fn multibyte_that_fits_is_untouched() {
680        // U+2212 MINUS SIGN (3 bytes) plus " 12 dB".
681        let text = "−12 dB";
682        let (n, s) = run(text, 32);
683        assert_eq!(n, text.len());
684        assert_eq!(s, text);
685    }
686}