Skip to main content

truce_core/
snapshot.rs

1//! Lock-free (for the audio thread) publish slot for a plugin's custom
2//! state, so the host can serialize it without taking the plugin lock.
3//!
4//! A plugin opts in by overriding `PluginLogic::snapshot_into`. The
5//! shell then calls it on the audio thread after each process block and
6//! publishes the bytes here; the wrapper's `save_state` reads them off
7//! this slot instead of locking the plugin and calling `save_state()`.
8//!
9//! The audio thread publishes through `try_lock` so it never blocks -
10//! on contention (a reader mid-clone) it skips a block's publish and
11//! leaves the previous snapshot in place, at most one block stale. The
12//! host / editor readers take a blocking `lock`, contending only with
13//! each other; because the producer never blocks, there is no priority
14//! inversion on the audio side.
15
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::{Arc, Mutex, PoisonError};
18
19/// Capacity the publish buffer is pre-warmed to at construction, off the
20/// audio thread, so the first per-block publish doesn't allocate. Small
21/// state (a label, a few flags, a file path) fits without ever growing;
22/// larger state reallocates once on the first publish, then stays warm.
23const SNAPSHOT_PREALLOC: usize = 256;
24
25/// Shared handle to the published custom-state bytes. Held by the shell
26/// (producer, audio thread) and the format wrapper (consumer, host /
27/// GUI thread), both cloned from one `Arc`.
28pub struct SnapshotSlot {
29    bytes: Mutex<Vec<u8>>,
30    /// Set once the plugin publishes a snapshot for the first time.
31    /// Until then a reader falls back to the locked `save_state()` path.
32    supported: AtomicBool,
33}
34
35impl SnapshotSlot {
36    /// A fresh slot with its publish buffer pre-warmed to
37    /// `SNAPSHOT_PREALLOC`. Nothing is published yet, so [`Self::read`]
38    /// returns `None` until the first [`Self::publish`].
39    #[must_use]
40    pub fn new() -> Arc<Self> {
41        let slot = Self {
42            bytes: Mutex::new(Vec::with_capacity(SNAPSHOT_PREALLOC)),
43            supported: AtomicBool::new(false),
44        };
45        // Warm the lock off the audio thread: some platforms' std `Mutex`
46        // (macOS boxes a `pthread_mutex_t`) lazily allocate the OS mutex
47        // on first lock, which would otherwise land on the first
48        // `publish` from the audio thread. Locking here forces that
49        // one-time init at construction instead.
50        drop(slot.bytes.lock().unwrap_or_else(PoisonError::into_inner));
51        Arc::new(slot)
52    }
53
54    /// Audio thread: publish the current snapshot. The buffer is
55    /// **cleared before `write` runs** (its capacity is retained, so a
56    /// steady state stays allocation-free), so `write` just fills it and
57    /// returns whether a snapshot exists - matching the
58    /// `PluginLogic::snapshot_into` contract, where a writer that only
59    /// `extend`s must not accumulate across blocks. Never blocks - on
60    /// lock contention with a reader the publish is skipped and the
61    /// previous snapshot stands.
62    pub fn publish(&self, write: impl FnOnce(&mut Vec<u8>) -> bool) {
63        if let Ok(mut guard) = self.bytes.try_lock() {
64            guard.clear();
65            if write(&mut guard) {
66                self.supported.store(true, Ordering::Release);
67            }
68        }
69    }
70
71    /// Whether the plugin has ever published a snapshot. Cheap atomic
72    /// read (no lock). Once true it stays true: a plugin's decision to
73    /// publish snapshots is a static capability, latched on the first
74    /// successful publish.
75    #[must_use]
76    pub fn is_supported(&self) -> bool {
77        self.supported.load(Ordering::Acquire)
78    }
79
80    /// Host / GUI thread: the latest published snapshot, or `None` when
81    /// the plugin doesn't publish snapshots (nothing ever written) so
82    /// the caller can fall back to the locked `save_state()` path.
83    #[must_use]
84    pub fn read(&self) -> Option<Vec<u8>> {
85        if !self.supported.load(Ordering::Acquire) {
86            return None;
87        }
88        Some(
89            self.bytes
90                .lock()
91                .unwrap_or_else(PoisonError::into_inner)
92                .clone(),
93        )
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::{SNAPSHOT_PREALLOC, SnapshotSlot};
100
101    #[test]
102    fn buffer_is_prewarmed_so_first_publish_does_not_allocate() {
103        let slot = SnapshotSlot::new();
104        // A first publish that fits inside the pre-warmed capacity must
105        // not grow the buffer - that is the whole point of warming it
106        // off the audio thread.
107        slot.publish(|buf| {
108            buf.extend_from_slice(&[0xAB; SNAPSHOT_PREALLOC]);
109            true
110        });
111        let published = slot.read().expect("published");
112        assert_eq!(published.len(), SNAPSHOT_PREALLOC);
113    }
114
115    #[test]
116    fn read_is_none_until_first_publish() {
117        let slot = SnapshotSlot::new();
118        assert!(slot.read().is_none());
119        slot.publish(|buf| {
120            buf.extend_from_slice(&[1, 2, 3]);
121            true
122        });
123        assert_eq!(slot.read(), Some(vec![1, 2, 3]));
124    }
125
126    #[test]
127    fn unsupported_publish_never_marks_supported() {
128        let slot = SnapshotSlot::new();
129        slot.publish(|_| false);
130        assert!(slot.read().is_none());
131    }
132
133    #[test]
134    fn is_supported_latches_on_first_true_publish() {
135        let slot = SnapshotSlot::new();
136        assert!(!slot.is_supported());
137        slot.publish(|_| false);
138        assert!(!slot.is_supported());
139        slot.publish(|buf| {
140            buf.push(1);
141            true
142        });
143        assert!(slot.is_supported());
144        // A later empty (but true) publish keeps it supported. `buf`
145        // arrives cleared, so returning true without writing publishes an
146        // empty blob.
147        slot.publish(|_| true);
148        assert!(slot.is_supported());
149        assert_eq!(slot.read(), Some(vec![]));
150    }
151
152    #[test]
153    fn writer_that_only_appends_does_not_accumulate_across_publishes() {
154        // Regression: the framework clears the buffer before each writer
155        // runs, so a plugin `snapshot_into` that only `extend`s (per the
156        // `PluginLogic::snapshot_into` "cleared first" contract) must not
157        // append the new snapshot onto the previous one.
158        let slot = SnapshotSlot::new();
159        slot.publish(|buf| {
160            buf.extend_from_slice(&[9; 64]);
161            true
162        });
163        assert_eq!(slot.read(), Some(vec![9; 64]));
164
165        slot.publish(|buf| {
166            buf.extend_from_slice(&[7, 7]);
167            true
168        });
169        assert_eq!(
170            slot.read(),
171            Some(vec![7, 7]),
172            "second publish must replace, not append onto, the first"
173        );
174    }
175}