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}