Skip to main content

vst3_host/
realtime.rs

1//! Lock-free real-time plugin runner.
2//!
3//! [`Vst3Host::play`](crate::Vst3Host::play) / [`simple::play`](crate::simple::play) are the
4//! friendly path: they wrap the plugin in an `Arc<Mutex<Plugin>>` and the audio callback
5//! locks it. That's correctness-first but not hard-real-time — a control-thread call can
6//! contend with the audio thread for the lock.
7//!
8//! [`RealtimePluginRunner`] is the serious path *alongside* it. The runner **owns** the
9//! plugin on the audio thread; control commands (MIDI, parameter changes) are delivered over
10//! a lock-free SPSC ring and applied at the start of each block. The audio callback never
11//! takes a lock a control thread could be holding, so it can't be blocked by `set_parameter`
12//! or `send_midi`.
13//!
14//! ```no_run
15//! use vst3_host::{simple, realtime::RealtimePluginRunner, midi::MidiChannel, audio::AudioBuffers};
16//! # fn main() -> vst3_host::Result<()> {
17//! let plugin = simple::load_plugin("/path/synth.vst3")?;
18//! let (mut runner, mut control) = RealtimePluginRunner::new(plugin, 1024);
19//! runner.start()?;
20//!
21//! // From any thread: queue control changes without locking the audio thread.
22//! control.send_midi(vst3_host::midi::MidiEvent::NoteOn { channel: MidiChannel::Ch1, note: 60, velocity: 100 });
23//!
24//! // On the audio thread (e.g. your device callback): drain commands + render, no locks.
25//! let mut buffers = AudioBuffers::new(0, 2, 512, 48_000.0);
26//! runner.process(&mut buffers)?;
27//!
28//! // Stop the audio side, then perform thread-affine COM teardown here.
29//! runner.stop()?;
30//! drop(runner);
31//! let _destroyed = control.service_teardown();
32//! # Ok(())
33//! # }
34//! ```
35
36use crate::{audio::AudioBuffers, error::Result, midi::MidiEvent, plugin::Plugin};
37use rtrb::{Consumer, Producer, RingBuffer};
38use std::{
39    mem::ManuallyDrop,
40    sync::mpsc::{sync_channel, Receiver, SyncSender, TryRecvError, TrySendError},
41    thread::{self, ThreadId},
42};
43
44/// Whether `value` is a usable normalized parameter value: finite and within `0.0..=1.0`.
45///
46/// Checked on the control thread before a parameter change is queued. A bad value that reached
47/// the ring would be rejected inside the plugin on the *audio* thread, where the rejection
48/// allocates an error string and is then discarded — long after the caller was told the change
49/// had been accepted.
50pub(crate) fn is_normalized(value: f64) -> bool {
51    value.is_finite() && (0.0..=1.0).contains(&value)
52}
53
54/// Drain at most one ring's worth of queued commands from `rx`, handing each to `apply`.
55/// Returns how many were applied.
56///
57/// The bound is what makes this safe to call from an audio callback: the ring never fills while
58/// a drain keeps pace with it, so an unbounded `while let Ok(..) = pop()` lets a control thread
59/// pushing in a tight loop pin the callback for as long as it keeps pushing. Commands still
60/// queued when the budget runs out are applied on the next block.
61pub(crate) fn drain_commands<T>(rx: &mut Consumer<T>, mut apply: impl FnMut(T)) -> usize {
62    let budget = rx.buffer().capacity();
63    let mut applied = 0;
64    for _ in 0..budget {
65        let Ok(command) = rx.pop() else {
66            break;
67        };
68        apply(command);
69        applied += 1;
70    }
71    applied
72}
73
74/// A runtime transport change applied to the plugin's host `ProcessContext` on the audio
75/// thread, taking effect on the next block. Shared by the lock-free runner and the
76/// mutex-based playback path so both apply transport mutation the same way.
77#[derive(Clone, Copy)]
78pub(crate) enum TransportCommand {
79    /// Set the transport tempo (BPM).
80    Tempo(f64),
81    /// Set the transport time signature (`numerator`, `denominator`).
82    TimeSignature(i32, i32),
83    /// Toggle the transport playing state.
84    Playing(bool),
85}
86
87impl TransportCommand {
88    /// Apply this transport change to the plugin, ignoring errors as the audio thread does for
89    /// all queued control. The value was validated on the control thread before being queued.
90    pub(crate) fn apply(self, plugin: &mut Plugin) {
91        match self {
92            TransportCommand::Tempo(bpm) => {
93                let _ = plugin.set_tempo(bpm);
94            }
95            TransportCommand::TimeSignature(num, den) => {
96                let _ = plugin.set_time_signature(num, den);
97            }
98            TransportCommand::Playing(playing) => {
99                let _ = plugin.set_playing(playing);
100            }
101        }
102    }
103}
104
105/// A control command applied to the plugin on the audio thread.
106enum RtCommand {
107    /// Deliver a MIDI event at `offset` samples into the next block.
108    Midi { event: MidiEvent, offset: i32 },
109    /// Set a normalized parameter value on the next block.
110    ///
111    /// Applying this only adds a point to the processor's next input-parameter queue.
112    /// `IEditController::setParamNormalized` is never invoked from the audio callback.
113    Param { id: u32, value: f64 },
114    /// Apply a transport change (tempo / time signature / playing) on the next block.
115    Transport(TransportCommand),
116}
117
118/// Owns a [`Plugin`] on the audio thread and applies queued control commands before each
119/// process block. Pair with an [`RtControl`] (returned from [`Self::new`]) to drive it from
120/// other threads.
121///
122/// # Real-time safety
123///
124/// In steady state [`process`](Self::process) is **allocation-free and `Drop`-free**: once
125/// warmed up it performs no heap allocation, reallocation, or free per block, even while
126/// parameter changes and MIDI (in and out) are flowing. This holds under two conditions:
127///
128/// - **Fixed buffer size** — pass an [`AudioBuffers`] sized to the configured block size and
129///   don't resize it between calls (a smaller block is fine; growth reallocates).
130/// - **In-process** — the runner hosts the plugin in-process; the process-isolation path
131///   marshals audio over IPC and is not allocation-free.
132///
133/// This is verified by `tests/alloc_tests.rs` (a counting global allocator asserts zero
134/// alloc/realloc/free over a steady-state run driving parameters and MIDI). The host cannot
135/// guarantee the *plugin's* own `process()` is allocation-free — that is the plugin's
136/// responsibility; the guarantee is about the host code around it.
137///
138/// # Threading model
139///
140/// Queued parameter and mapped-MIDI commands populate the processor's input parameter queues on
141/// the audio thread and park the same values for `IEditController`, which is a main-thread-domain
142/// interface: the plugin applies them when a control thread next touches it (see
143/// [`RtControl::set_parameter`]). No controller call is ever made from this runner.
144///
145/// It is **not yet fully lock-free**: `process` still takes a few short, uncontended mutexes
146/// per block (the parameter-change and event queues, and the level meter). They are uncontended
147/// while the runner owns the plugin, but a hard-real-time deployment should treat lock removal
148/// as pending work. Output MIDI is already lock-free, though: take a
149/// [`OutputMidiConsumer`](crate::OutputMidiConsumer) via
150/// [`Plugin::output_midi_handle`](crate::Plugin::output_midi_handle) before moving the plugin
151/// into the runner, then drain emitted events from your UI thread while the audio thread pushes.
152pub struct RealtimePluginRunner {
153    plugin: Option<Plugin>,
154    rx: Consumer<RtCommand>,
155    teardown_tx: SyncSender<ManuallyDrop<Plugin>>,
156}
157
158/// A `Send` handle for pushing MIDI and parameter changes to a [`RealtimePluginRunner`]
159/// without locking. The runner lives on the audio thread; this handle may move between threads,
160/// but plugin teardown is serviced only on the thread where [`RealtimePluginRunner::new`] created
161/// it.
162pub struct RtControl {
163    tx: Producer<RtCommand>,
164    /// Count of commands dropped because the queue was full (observability).
165    dropped: u64,
166    teardown: OwnerThreadTeardown<Plugin>,
167}
168
169/// The receive half of a one-slot teardown handoff.
170///
171/// Values are wrapped in `ManuallyDrop` before they enter the channel. This is important:
172/// destroying a disconnected receiver normally drops queued values on whichever thread drops
173/// the receiver. Here an off-owner drop leaks queued values instead, preserving the plugin's COM
174/// and module-unload thread affinity.
175struct OwnerThreadTeardown<T> {
176    rx: Receiver<ManuallyDrop<T>>,
177    owner_thread: ThreadId,
178}
179
180impl<T> OwnerThreadTeardown<T> {
181    fn service_one(&mut self) -> bool {
182        if thread::current().id() != self.owner_thread {
183            return false;
184        }
185        match self.rx.try_recv() {
186            Ok(value) => {
187                drop(ManuallyDrop::into_inner(value));
188                true
189            }
190            Err(TryRecvError::Empty | TryRecvError::Disconnected) => false,
191        }
192    }
193}
194
195impl<T> Drop for OwnerThreadTeardown<T> {
196    fn drop(&mut self) {
197        if thread::current().id() != self.owner_thread {
198            return;
199        }
200        while self.service_one() {}
201    }
202}
203
204fn teardown_handoff<T>() -> (SyncSender<ManuallyDrop<T>>, OwnerThreadTeardown<T>) {
205    let (tx, rx) = sync_channel(1);
206    (
207        tx,
208        OwnerThreadTeardown {
209            rx,
210            owner_thread: thread::current().id(),
211        },
212    )
213}
214
215/// Hand a value to its owner thread without blocking. Both error variants deliberately leak the
216/// `ManuallyDrop` payload when the receiver is gone or the one-slot queue is occupied.
217fn try_handoff_teardown<T>(tx: &SyncSender<ManuallyDrop<T>>, value: T) -> bool {
218    match tx.try_send(ManuallyDrop::new(value)) {
219        Ok(()) => true,
220        Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => false,
221    }
222}
223
224impl RealtimePluginRunner {
225    /// Build a runner that owns `plugin`, plus the [`RtControl`] handle to drive it.
226    ///
227    /// `command_capacity` is the maximum number of MIDI/parameter commands that can be
228    /// queued between two [`process`](Self::process) calls; pushes beyond it are dropped
229    /// (reported by the `RtControl` methods returning `false`). Size it for your block rate
230    /// and worst-case control burst (e.g. 1024).
231    ///
232    /// Call this on the same control thread that loaded the plugin. If the runner is later
233    /// dropped on an audio thread, the plugin is handed back to the returned [`RtControl`] for
234    /// destruction on this thread. Call [`RtControl::service_teardown`] after the runner has
235    /// stopped, or drop the control on this thread.
236    pub fn new(plugin: Plugin, command_capacity: usize) -> (Self, RtControl) {
237        let (tx, rx) = RingBuffer::new(command_capacity.max(1));
238        let (teardown_tx, teardown) = teardown_handoff();
239        (
240            Self {
241                plugin: Some(plugin),
242                rx,
243                teardown_tx,
244            },
245            RtControl {
246                tx,
247                dropped: 0,
248                teardown,
249            },
250        )
251    }
252
253    /// Begin processing. Call once before the first [`process`](Self::process).
254    pub fn start(&mut self) -> Result<()> {
255        self.plugin
256            .as_mut()
257            .expect("runner plugin missing")
258            .start_processing()
259    }
260
261    /// Stop processing.
262    pub fn stop(&mut self) -> Result<()> {
263        self.plugin
264            .as_mut()
265            .expect("runner plugin missing")
266            .stop_processing()
267    }
268
269    /// Drain queued control commands and render one block.
270    ///
271    /// Call this from the audio thread (e.g. inside your device callback). It performs only
272    /// the lock-free queue drain plus the plugin's own processing — it never blocks on a lock
273    /// a control thread could hold.
274    ///
275    /// The drain is bounded by the command queue's capacity. A control thread pushing in a
276    /// tight loop refills the queue as fast as this drains it, so an unbounded drain would pin
277    /// the audio callback; anything still queued is applied on the next block instead.
278    pub fn process(&mut self, buffers: &mut AudioBuffers) -> Result<()> {
279        let plugin = self.plugin.as_mut().expect("runner plugin missing");
280        let rx = &mut self.rx;
281        drain_commands(rx, |command| match command {
282            RtCommand::Midi { event, offset } => {
283                let _ = plugin.send_midi_event_at(event, offset);
284            }
285            RtCommand::Param { id, value } => {
286                let _ = plugin.queue_processor_parameter_at(id, value, 0);
287            }
288            RtCommand::Transport(change) => {
289                change.apply(plugin);
290            }
291        });
292        plugin.process_audio(buffers)
293    }
294
295    /// Borrow the underlying plugin (e.g. to read parameters or info). Do **not** call this
296    /// from the audio thread while another thread might also touch the plugin.
297    pub fn plugin(&self) -> &Plugin {
298        self.plugin.as_ref().expect("runner plugin missing")
299    }
300
301    /// Recover the owned plugin, consuming the runner.
302    pub fn into_plugin(mut self) -> Plugin {
303        self.plugin.take().expect("runner plugin missing")
304    }
305}
306
307impl Drop for RealtimePluginRunner {
308    fn drop(&mut self) {
309        let Some(plugin) = self.plugin.take() else {
310            return;
311        };
312        // Never destroy the plugin on this (possibly real-time) thread. The bounded handoff is
313        // nonblocking; a disconnected/full queue leaks rather than running COM termination or
314        // unloading executable code here.
315        let _ = try_handoff_teardown(&self.teardown_tx, plugin);
316    }
317}
318
319impl RtControl {
320    /// Destroy a plugin handed back by a dropped [`RealtimePluginRunner`].
321    ///
322    /// This call never waits for the runner. It returns `true` only when a pending plugin was
323    /// destroyed. It must be called on the thread where [`RealtimePluginRunner::new`] created
324    /// this control; calls from any other thread return `false` and leave the handoff queued.
325    ///
326    /// Dropping `RtControl` on its creation thread services any pending handoff automatically.
327    /// Dropping it elsewhere deliberately leaks a pending plugin rather than releasing COM
328    /// objects and unloading the plugin bundle on the wrong thread.
329    pub fn service_teardown(&mut self) -> bool {
330        self.teardown.service_one()
331    }
332
333    /// Queue a MIDI event for the next block (at block start). Returns `false` if the command
334    /// queue is full (the event is dropped rather than blocking the caller).
335    pub fn send_midi(&mut self, event: MidiEvent) -> bool {
336        self.send_midi_at(event, 0)
337    }
338
339    /// Queue a MIDI event scheduled at `sample_offset` samples into the next block, for
340    /// sample-accurate sequencing. A negative offset is floored to `0`; `process()` clamps it
341    /// into the actual (possibly shorter) block. Returns `false` if the queue is full.
342    pub fn send_midi_at(&mut self, event: MidiEvent, sample_offset: i32) -> bool {
343        let ok = self
344            .tx
345            .push(RtCommand::Midi {
346                event,
347                offset: sample_offset.max(0),
348            })
349            .is_ok();
350        self.track(ok)
351    }
352
353    /// Queue a normalized parameter change for the next block. `value` must be finite and
354    /// within `0.0..=1.0`; an invalid value is rejected here (returns `false`) rather than
355    /// queued, so the caller learns about it instead of the audio thread silently discarding
356    /// it. Returns `false` if the queue is full.
357    ///
358    /// # The editor catches up later
359    ///
360    /// The audio thread applies the value to the plugin's DSP, but `IEditController` belongs to
361    /// the main-thread domain, so the plugin's *own editor* (and
362    /// [`Plugin::get_parameter`](crate::Plugin::get_parameter),
363    /// [`format_parameter`](crate::Plugin::format_parameter) and saved state) is updated from
364    /// the control thread instead. That happens the next time the control thread touches the
365    /// plugin — reading a parameter, draining
366    /// [`Plugin::get_parameter_changes`](crate::Plugin::get_parameter_changes), or calling
367    /// [`Plugin::service_host_requests`](crate::Plugin::service_host_requests). A host that
368    /// polls the plugin every UI frame (the usual editor loop) never notices the gap; a host
369    /// that never calls back in will see a stale editor. The queue is bounded and drops its
370    /// oldest entry when full, so the newest value for a parameter always wins.
371    pub fn set_parameter(&mut self, id: u32, value: f64) -> bool {
372        if !is_normalized(value) {
373            return false;
374        }
375        let ok = self.tx.push(RtCommand::Param { id, value }).is_ok();
376        self.track(ok)
377    }
378
379    /// Queue a transport tempo change (BPM) for the next block. `bpm` must be finite and
380    /// greater than `0`; an invalid value is rejected (returns `false`) rather than queued.
381    /// Returns `false` if the queue is full.
382    pub fn set_tempo(&mut self, bpm: f64) -> bool {
383        if !(bpm.is_finite() && bpm > 0.0) {
384            return false;
385        }
386        let ok = self
387            .tx
388            .push(RtCommand::Transport(TransportCommand::Tempo(bpm)))
389            .is_ok();
390        self.track(ok)
391    }
392
393    /// Queue a transport time-signature change for the next block. `denominator` must be one
394    /// of `1, 2, 4, 8, 16` and `numerator` must be positive; an invalid value is rejected
395    /// (returns `false`). Returns `false` if the queue is full.
396    pub fn set_time_signature(&mut self, numerator: i32, denominator: i32) -> bool {
397        if numerator <= 0 || !matches!(denominator, 1 | 2 | 4 | 8 | 16) {
398            return false;
399        }
400        let ok = self
401            .tx
402            .push(RtCommand::Transport(TransportCommand::TimeSignature(
403                numerator,
404                denominator,
405            )))
406            .is_ok();
407        self.track(ok)
408    }
409
410    /// Queue a transport playing-state toggle for the next block. Returns `false` if the queue
411    /// is full.
412    pub fn set_playing(&mut self, playing: bool) -> bool {
413        let ok = self
414            .tx
415            .push(RtCommand::Transport(TransportCommand::Playing(playing)))
416            .is_ok();
417        self.track(ok)
418    }
419
420    /// Total number of commands dropped because the queue was full since this control was
421    /// created. A persistently rising count means the queue capacity is too small for the
422    /// control rate.
423    pub fn dropped_command_count(&self) -> u64 {
424        self.dropped
425    }
426
427    fn track(&mut self, ok: bool) -> bool {
428        if !ok {
429            self.dropped += 1;
430        }
431        ok
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::midi::MidiChannel;
439    use std::sync::{
440        atomic::{AtomicUsize, Ordering},
441        Arc, Mutex,
442    };
443
444    fn test_control(tx: Producer<RtCommand>) -> RtControl {
445        let (_teardown_tx, teardown) = teardown_handoff();
446        RtControl {
447            tx,
448            dropped: 0,
449            teardown,
450        }
451    }
452
453    #[test]
454    fn control_queue_reports_full_without_blocking() {
455        // A tiny capacity makes the drop-on-full behavior observable without a plugin.
456        let (tx, _rx) = RingBuffer::<RtCommand>::new(2);
457        let mut control = test_control(tx);
458        assert!(control.set_parameter(1, 0.5));
459        assert!(control.set_parameter(1, 0.6));
460        // Third push exceeds capacity (nothing has been drained) → dropped, not blocked.
461        assert!(!control.set_parameter(1, 0.7));
462        assert!(!control.send_midi(crate::midi::MidiEvent::NoteOn {
463            channel: crate::midi::MidiChannel::Ch1,
464            note: 60,
465            velocity: 100
466        }));
467        assert_eq!(control.dropped_command_count(), 2);
468    }
469
470    #[test]
471    fn transport_commands_round_trip_through_the_ring() {
472        let (tx, mut rx) = RingBuffer::<RtCommand>::new(8);
473        let mut control = test_control(tx);
474
475        assert!(control.set_tempo(140.0));
476        assert!(control.set_time_signature(7, 8));
477        assert!(control.set_playing(false));
478
479        // The three transport commands arrive in order, carrying their payloads intact.
480        match rx.pop().expect("tempo queued") {
481            RtCommand::Transport(TransportCommand::Tempo(bpm)) => assert_eq!(bpm, 140.0),
482            _ => panic!("expected tempo transport command"),
483        }
484        match rx.pop().expect("time sig queued") {
485            RtCommand::Transport(TransportCommand::TimeSignature(n, d)) => {
486                assert_eq!((n, d), (7, 8))
487            }
488            _ => panic!("expected time-signature transport command"),
489        }
490        match rx.pop().expect("playing queued") {
491            RtCommand::Transport(TransportCommand::Playing(p)) => assert!(!p),
492            _ => panic!("expected playing transport command"),
493        }
494    }
495
496    #[test]
497    fn midi_offset_round_trips_through_the_ring() {
498        let (tx, mut rx) = RingBuffer::<RtCommand>::new(8);
499        let mut control = test_control(tx);
500
501        assert!(control.send_midi_at(
502            MidiEvent::NoteOn {
503                channel: MidiChannel::Ch1,
504                note: 60,
505                velocity: 100,
506            },
507            128,
508        ));
509        // send_midi is the offset-0 convenience.
510        assert!(control.send_midi(MidiEvent::NoteOff {
511            channel: MidiChannel::Ch1,
512            note: 60,
513            velocity: 0,
514        }));
515
516        match rx.pop().expect("scheduled note queued") {
517            RtCommand::Midi { offset, .. } => assert_eq!(offset, 128),
518            _ => panic!("expected a MIDI command"),
519        }
520        match rx.pop().expect("block-start note queued") {
521            RtCommand::Midi { offset, .. } => assert_eq!(offset, 0),
522            _ => panic!("expected a MIDI command"),
523        }
524    }
525
526    /// `set_parameter` documents normalized values. A NaN or a `7.3` that reached the ring is
527    /// rejected on the *audio* thread — allocating an error string there and vanishing silently
528    /// after the caller was told the change succeeded.
529    #[test]
530    fn out_of_range_parameter_values_are_rejected_not_queued() {
531        let (tx, mut rx) = RingBuffer::<RtCommand>::new(8);
532        let mut control = test_control(tx);
533
534        for bad in [
535            f64::NAN,
536            f64::INFINITY,
537            f64::NEG_INFINITY,
538            -0.1,
539            1.000_001,
540            7.3,
541        ] {
542            assert!(!control.set_parameter(1, bad), "{bad} must be rejected");
543        }
544        assert!(rx.pop().is_err(), "no invalid value reached the ring");
545        // Rejected on validation, not because the queue was full.
546        assert_eq!(control.dropped_command_count(), 0);
547
548        // Both endpoints of the normalized range are valid.
549        assert!(control.set_parameter(1, 0.0));
550        assert!(control.set_parameter(1, 1.0));
551        assert_eq!(rx.slots(), 2);
552    }
553
554    #[test]
555    fn is_normalized_accepts_exactly_the_unit_interval() {
556        assert!(is_normalized(0.0) && is_normalized(0.5) && is_normalized(1.0));
557        for bad in [
558            f64::NAN,
559            f64::INFINITY,
560            f64::NEG_INFINITY,
561            -1e-9,
562            1.0 + 1e-9,
563        ] {
564            assert!(!is_normalized(bad), "{bad} is not normalized");
565        }
566    }
567
568    /// An unbounded `while let Ok(..) = pop()` on the audio thread can be pinned indefinitely by
569    /// a control thread that pushes as fast as the callback drains: the ring never fills, so the
570    /// loop never ends. The drain is capped at one ring's worth per block instead.
571    #[test]
572    fn drain_commands_stops_after_one_ring_even_while_the_producer_refills() {
573        let (mut tx, mut rx) = RingBuffer::<u32>::new(4);
574        for i in 0..4 {
575            tx.push(i).expect("ring holds 4");
576        }
577
578        // Refill from inside the drain, standing in for the tight-loop control thread.
579        let mut seen = Vec::new();
580        let applied = drain_commands(&mut rx, |command| {
581            seen.push(command);
582            let _ = tx.push(100 + command);
583        });
584
585        assert_eq!(applied, 4, "exactly one ring's worth per call");
586        assert_eq!(seen, vec![0, 1, 2, 3]);
587        assert_eq!(
588            rx.slots(),
589            4,
590            "the commands pushed during the drain wait for the next block"
591        );
592    }
593
594    #[test]
595    fn drain_commands_stops_early_on_an_empty_ring() {
596        let (mut tx, mut rx) = RingBuffer::<u32>::new(64);
597        tx.push(7).expect("room");
598        let mut seen = Vec::new();
599        assert_eq!(drain_commands(&mut rx, |c| seen.push(c)), 1);
600        assert_eq!(seen, vec![7]);
601        assert_eq!(drain_commands(&mut rx, |c| seen.push(c)), 0);
602    }
603
604    #[test]
605    fn invalid_transport_values_are_rejected_not_queued() {
606        let (tx, _rx) = RingBuffer::<RtCommand>::new(8);
607        let mut control = test_control(tx);
608        // Non-positive / non-finite tempo and malformed time signatures never reach the ring.
609        assert!(!control.set_tempo(0.0));
610        assert!(!control.set_tempo(f64::NAN));
611        assert!(!control.set_time_signature(0, 4));
612        assert!(!control.set_time_signature(4, 3));
613        // Rejected on validation, not because the queue was full.
614        assert_eq!(control.dropped_command_count(), 0);
615    }
616
617    struct DropProbe {
618        drops: Arc<AtomicUsize>,
619        threads: Arc<Mutex<Vec<ThreadId>>>,
620    }
621
622    impl Drop for DropProbe {
623        fn drop(&mut self) {
624            self.drops.fetch_add(1, Ordering::SeqCst);
625            self.threads
626                .lock()
627                .expect("drop thread log")
628                .push(thread::current().id());
629        }
630    }
631
632    fn drop_probe() -> (DropProbe, Arc<AtomicUsize>, Arc<Mutex<Vec<ThreadId>>>) {
633        let drops = Arc::new(AtomicUsize::new(0));
634        let threads = Arc::new(Mutex::new(Vec::new()));
635        (
636            DropProbe {
637                drops: Arc::clone(&drops),
638                threads: Arc::clone(&threads),
639            },
640            drops,
641            threads,
642        )
643    }
644
645    #[test]
646    fn teardown_is_serviced_only_on_the_captured_owner_thread() {
647        let owner = thread::current().id();
648        let (teardown_tx, teardown) = teardown_handoff();
649        let (probe, drops, threads) = drop_probe();
650        assert!(try_handoff_teardown(&teardown_tx, probe));
651
652        let mut teardown = thread::spawn(move || {
653            let mut teardown = teardown;
654            assert!(!teardown.service_one());
655            teardown
656        })
657        .join()
658        .expect("non-owner service thread");
659
660        assert_eq!(drops.load(Ordering::SeqCst), 0);
661        assert!(teardown.service_one());
662        assert_eq!(drops.load(Ordering::SeqCst), 1);
663        assert_eq!(*threads.lock().expect("drop thread log"), vec![owner]);
664    }
665
666    #[test]
667    fn dropping_teardown_receiver_off_owner_leaks_queued_value() {
668        let (teardown_tx, teardown) = teardown_handoff();
669        let (probe, drops, _threads) = drop_probe();
670        assert!(try_handoff_teardown(&teardown_tx, probe));
671
672        thread::spawn(move || drop(teardown))
673            .join()
674            .expect("off-owner drop thread");
675        drop(teardown_tx);
676
677        assert_eq!(
678            drops.load(Ordering::SeqCst),
679            0,
680            "thread-affine value must not be destroyed off its owner thread"
681        );
682    }
683
684    #[test]
685    fn disconnected_or_full_handoff_leaks_instead_of_dropping_the_value() {
686        let (disconnected_tx, disconnected_rx) = teardown_handoff();
687        drop(disconnected_rx);
688        let (disconnected_probe, disconnected_drops, _) = drop_probe();
689        assert!(!try_handoff_teardown(&disconnected_tx, disconnected_probe));
690        assert_eq!(disconnected_drops.load(Ordering::SeqCst), 0);
691
692        let (full_tx, mut full_rx) = teardown_handoff();
693        let (queued_probe, queued_drops, _) = drop_probe();
694        let (overflow_probe, overflow_drops, _) = drop_probe();
695        assert!(try_handoff_teardown(&full_tx, queued_probe));
696        assert!(!try_handoff_teardown(&full_tx, overflow_probe));
697        assert_eq!(overflow_drops.load(Ordering::SeqCst), 0);
698        assert!(full_rx.service_one());
699        assert_eq!(queued_drops.load(Ordering::SeqCst), 1);
700    }
701}