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 /// The `extern "C"` firewall: a body that panics returns the fallback
627 /// (so a panic can't unwind across the ABI and abort the caller), and
628 /// a clean body returns its value. The generated `__truce_screenshot`
629 /// symbol relies on this.
630 #[test]
631 fn run_extern_callback_with_catches_panic_and_returns_fallback() {
632 let ok = super::run_extern_callback_with::<(), u32>("test", "clean", 9, || 3);
633 assert_eq!(ok, 3, "a clean body returns its own value");
634
635 let caught =
636 super::run_extern_callback_with::<(), u32>("test", "boom", 9, || panic!("boom"));
637 assert_eq!(caught, 9, "a panicking body returns the fallback");
638 }
639}
640
641#[cfg(test)]
642mod copy_c_str_tests {
643 use super::copy_c_str;
644 use std::os::raw::c_char;
645
646 /// Copy `text` into a `cap`-byte buffer; return `(written_len, content)`
647 /// where `content` is the NUL-terminated bytes read back as a `String`.
648 fn run(text: &str, cap: usize) -> (usize, String) {
649 let mut buf = vec![0 as c_char; cap];
650 // SAFETY: `buf` is `cap` `c_char`, and `cap > 0` in every case here.
651 let n = unsafe { copy_c_str(buf.as_mut_ptr(), cap, text) };
652 // `c_char` -> `u8` is the bit-preserving inverse of the copy.
653 #[allow(clippy::cast_sign_loss)]
654 let bytes: Vec<u8> = buf[..n].iter().map(|&c| c as u8).collect();
655 (n, String::from_utf8(bytes).unwrap())
656 }
657
658 #[test]
659 fn copies_when_it_fits() {
660 let (n, s) = run("-6 dB", 32);
661 assert_eq!(n, 5);
662 assert_eq!(s, "-6 dB");
663 }
664
665 #[test]
666 fn writes_the_trailing_nul() {
667 let mut buf = [1 as c_char; 8];
668 // SAFETY: 8-byte buffer, capacity 8.
669 let n = unsafe { copy_c_str(buf.as_mut_ptr(), 8, "ab") };
670 assert_eq!(n, 2);
671 assert_eq!(buf[2], 0, "NUL terminator written after the content");
672 }
673
674 #[test]
675 fn truncates_ascii_to_capacity() {
676 // cap 4 -> 3 content bytes + NUL.
677 let (n, s) = run("abcdef", 4);
678 assert_eq!(n, 3);
679 assert_eq!(s, "abc");
680 }
681
682 #[test]
683 fn truncates_on_a_char_boundary() {
684 // "12°" is [0x31, 0x32, 0xC2, 0xB0]. cap 4 -> 3 content bytes would
685 // cut '°' (U+00B0) mid-codepoint; the guard backs off to 2 so the
686 // result is valid UTF-8, not a torn tail a strict host rejects.
687 let (n, s) = run("12°", 4);
688 assert_eq!(n, 2, "dropped the half-written degree sign");
689 assert_eq!(s, "12");
690 }
691
692 #[test]
693 fn multibyte_that_fits_is_untouched() {
694 // U+2212 MINUS SIGN (3 bytes) plus " 12 dB".
695 let text = "−12 dB";
696 let (n, s) = run(text, 32);
697 assert_eq!(n, text.len());
698 assert_eq!(s, text);
699 }
700}