truce_loader/static_shell.rs
1//! `StaticShell` - embeds the plugin directly into the binary.
2//!
3//! No dlopen, no file watcher, no Mutex. Same types as `HotShell`
4//! but zero runtime overhead. Use via `export_static!`.
5
6use std::sync::Arc;
7
8use truce_core::buffer::AudioBuffer;
9use truce_core::bus::BusLayout;
10use truce_core::config::AudioConfig;
11use truce_core::events::{EventBody, EventList};
12use truce_core::info::PluginInfo;
13use truce_core::meters::MeterStore;
14use truce_core::plugin::PluginRuntime;
15use truce_core::process::{ProcessContext, ProcessStatus};
16use truce_core::snapshot::{SnapshotPublisher, SnapshotSlot};
17use truce_core::state::{ForeignState, MigratedState, StateLoadError};
18use truce_core::tasks::{AnyTaskSpawner, InitContext, warm_pool};
19use truce_params::Params;
20use truce_params::sample::Sample;
21use truce_plugin::PluginLogicCore;
22
23// ---------------------------------------------------------------------------
24// StaticShell
25// ---------------------------------------------------------------------------
26
27/// A static plugin shell that embeds the user's `PluginLogic` impl
28/// directly into the format-wrapper binary.
29///
30/// Same bridging as `HotShell` but without `NativeLoader`, `Mutex`,
31/// file watching, or any dynamic loading overhead. Use via `export_static!`.
32pub struct StaticShell<P: Params, L: PluginLogicCore<S, Params = P>, S: Sample = f32> {
33 pub params: Arc<P>,
34 /// The user's mutable DSP state, owned by the shell (not the
35 /// descriptor `L`). Built once via `L::init(¶ms)`.
36 state: L::DspState,
37 meters: Arc<MeterStore>,
38 /// Lock-free publish slot for `snapshot_into`-based state save.
39 snapshots: Arc<SnapshotSlot>,
40 /// Stays `true` until the logic reports (via `snapshot_into`), on a
41 /// block before it ever publishes, that it has no custom snapshot -
42 /// after which per-block publishing is skipped so non-opt-in plugins
43 /// pay nothing. A plugin that has published once stays subscribed.
44 try_snapshot: bool,
45 /// Last `snapshot_version` the shell published. A block whose version
46 /// matches this skips re-serialization entirely (see
47 /// `publish_snapshot_with`). `None` until the first landed publish.
48 last_snapshot_version: Option<u64>,
49 sample_rate: f64,
50 /// Background-task spawner bundle (one lane per declared task type),
51 /// when the plugin wired `tasks:` on `plugin!`. Type-erased; stamped
52 /// into each block's `ProcessContext` so `ctx.tasks::<T>()` works.
53 /// `None` for a plugin with no background tasks.
54 tasks: Option<AnyTaskSpawner>,
55 _sample: std::marker::PhantomData<fn() -> S>,
56}
57
58// SAFETY: `StaticShell` owns `Arc<P>` (params, `Sync` by the
59// `Params` trait contract), `L::DspState` (`Send + 'static` per the
60// `PluginLogicCore` bound), an atomic-slot `MeterStore`, and a
61// `PhantomData<fn() -> S>`. No raw pointers, no `!Send` fields, no
62// interior mutability that escapes the shell's own `&mut` borrows. The
63// host contract that format wrappers invoke methods on a single thread
64// at a time per instance is what keeps the embedded state safe to
65// access without an inner mutex - same model `HotShell` uses through
66// `parking_lot::Mutex`.
67unsafe impl<P: Params, L: PluginLogicCore<S, Params = P>, S: Sample> Send for StaticShell<P, L, S> {}
68
69impl<P: Params + Default + 'static, L: PluginLogicCore<S, Params = P> + 'static, S: Sample>
70 StaticShell<P, L, S>
71{
72 /// Build the shell from shared params, constructing the initial DSP
73 /// state via `L::init(¶ms, &cx)`. The descriptor `L` is a
74 /// type-only marker; the shell owns the state it produces. `tasks` is
75 /// the plugin's background-task spawner (`Some` only when the plugin
76 /// wired `tasks:` on `plugin!`); it reaches `init` through the
77 /// `InitContext` and each block through the `ProcessContext`.
78 pub fn from_parts(params: Arc<P>, tasks: Option<AnyTaskSpawner>) -> Self {
79 // A wired spawner means the plugin may schedule background work,
80 // possibly first from `process()` (a filter that only rebuilds on a
81 // knob move, with nothing in `init` to warm the pool). Start the
82 // pool here, on the instantiation (main) thread, so the first
83 // audio-thread schedule never cold-starts worker threads inside the
84 // callback - keeping the spawner's "safe from the audio thread"
85 // guarantee true regardless of where the plugin first schedules.
86 if tasks.is_some() {
87 warm_pool();
88 }
89 // Build the snapshot slot before `init` so the plugin can capture
90 // a publisher for the off-thread (large-state) lane. Pre-warm the
91 // inline buffer to the plugin's hint so a first small publish
92 // doesn't allocate on the audio thread.
93 let snapshots = SnapshotSlot::with_capacity(L::snapshot_prealloc_hint());
94 let init_ctx =
95 InitContext::new(tasks.clone()).with_snapshot(SnapshotPublisher::new(&snapshots));
96 let state = L::init(¶ms, &init_ctx);
97 Self {
98 params,
99 state,
100 meters: MeterStore::new(),
101 snapshots,
102 try_snapshot: true,
103 last_snapshot_version: None,
104 sample_rate: 44100.0,
105 tasks,
106 _sample: std::marker::PhantomData,
107 }
108 }
109
110 /// Shared meter storage handle - the GUI-thread-safe channel
111 /// for meter reads (see `PluginExport::meter_store`).
112 pub fn meter_store(&self) -> Arc<MeterStore> {
113 Arc::clone(&self.meters)
114 }
115
116 /// Shared snapshot slot for lock-free state save (see
117 /// `PluginExport::snapshot_slot`).
118 pub fn snapshot_slot(&self) -> Arc<SnapshotSlot> {
119 Arc::clone(&self.snapshots)
120 }
121
122 /// The plugin's background-task spawner (see
123 /// `PluginExport::task_spawner`). `None` unless the plugin wired
124 /// `tasks:` on `plugin!`.
125 pub fn task_spawner(&self) -> Option<AnyTaskSpawner> {
126 self.tasks.clone()
127 }
128
129 /// Access the plugin's DSP state (for testing).
130 pub fn state_ref(&self) -> &L::DspState {
131 &self.state
132 }
133
134 /// Mutable access to the plugin's DSP state (for testing).
135 pub fn state_ref_mut(&mut self) -> &mut L::DspState {
136 &mut self.state
137 }
138}
139
140impl<P: Params + Default + 'static, L: PluginLogicCore<S, Params = P> + 'static, S: Sample>
141 PluginRuntime for StaticShell<P, L, S>
142{
143 type Sample = S;
144
145 fn info() -> PluginInfo
146 where
147 Self: Sized,
148 {
149 unreachable!("StaticShell::info() should not be called statically")
150 }
151
152 fn bus_layouts() -> Vec<BusLayout>
153 where
154 Self: Sized,
155 {
156 unreachable!("StaticShell::bus_layouts() should not be called statically")
157 }
158
159 fn init(&mut self) {}
160
161 fn reset(&mut self, config: &AudioConfig) {
162 self.sample_rate = config.sample_rate;
163 // Params plumbing is the shell's job, not the plugin's: settle
164 // smoother coefficients and state before the user's `reset` so
165 // its body reads post-snap values.
166 self.params.set_sample_rate(config.sample_rate);
167 self.params.snap_smoothers();
168 L::reset(&mut self.state, &self.params, config);
169 }
170
171 fn process(
172 &mut self,
173 buffer: &mut AudioBuffer<S>,
174 events: &EventList,
175 context: &mut ProcessContext,
176 ) -> ProcessStatus {
177 // Apply parameter change events to the shell's params.
178 // ParamChange values from format wrappers are PLAIN (already
179 // denormalized). `set_normalized` here would double-denormalize.
180 for e in events.iter() {
181 if let EventBody::ParamChange { id, value } = &e.body {
182 self.params.set_plain(*id, *value);
183 }
184 }
185
186 // No sync needed - plugin reads from the same Arc<Params>.
187
188 // Build a ProcessContext with param/meter callbacks for the logic.
189 let params = &self.params;
190 let meters = &self.meters;
191 let param_fn = |id: u32| -> f64 { params.get_plain(id).unwrap_or(0.0) };
192 let meter_fn = |id: u32, v: f32| meters.write(id, v);
193 let ctx = ProcessContext::new(
194 context.transport,
195 context.sample_rate,
196 buffer.num_samples(),
197 &mut *context.output_events,
198 )
199 .with_process_mode(context.process_mode)
200 .with_params(¶m_fn)
201 .with_meters(&meter_fn);
202 // Stamp the background-task spawner so `ctx.tasks::<T>()` works.
203 let mut ctx = match &self.tasks {
204 Some(t) => ctx.with_tasks(t),
205 None => ctx,
206 };
207
208 let status = L::process(&mut self.state, &self.params, buffer, events, &mut ctx);
209 publish_snapshot::<S, L>(
210 &self.state,
211 &self.snapshots,
212 &mut self.try_snapshot,
213 &mut self.last_snapshot_version,
214 );
215 status
216 }
217
218 fn save_state(&self) -> Vec<u8> {
219 L::save_state(&self.state)
220 }
221
222 fn snapshot_into(&self, buf: &mut Vec<u8>) -> bool {
223 L::snapshot_into(&self.state, buf)
224 }
225
226 fn republish_snapshot(&mut self) {
227 publish_snapshot::<S, L>(
228 &self.state,
229 &self.snapshots,
230 &mut self.try_snapshot,
231 &mut self.last_snapshot_version,
232 );
233 }
234
235 fn load_state(&mut self, data: &[u8]) -> Result<(), StateLoadError> {
236 let result = L::load_state(&mut self.state, data);
237 // Plugin-side cache invalidation runs in the same `&mut`
238 // borrow window so the next `process()` block sees the
239 // refreshed caches - fire it whether or not load_state
240 // succeeded so partial state still triggers a refresh.
241 L::state_changed(&mut self.state, &self.params);
242 // Invalidate the snapshot-version gate: the load replaced state
243 // without necessarily bumping `snapshot_version` (a counter that
244 // round-trips through the blob, or an author who forgets), so the
245 // next publish - the `republish_snapshot` the wrapper calls right
246 // after this - must re-serialize rather than skip on a stale
247 // version and leave pre-load bytes in the slot.
248 self.last_snapshot_version = None;
249 result
250 }
251
252 fn migrate_state(foreign: &ForeignState) -> Option<MigratedState>
253 where
254 Self: Sized,
255 {
256 <L as PluginLogicCore<S>>::migrate_state(foreign)
257 }
258
259 fn latency(&self) -> u32 {
260 L::latency(&self.state)
261 }
262 fn tail(&self) -> u32 {
263 L::tail(&self.state)
264 }
265
266 fn get_meter(&self, meter_id: u32) -> f32 {
267 self.meters.read(meter_id)
268 }
269}
270
271/// Publish the plugin's `snapshot_into` bytes into `slot` on the audio
272/// thread. Shared by both shells.
273///
274/// Opting into snapshots is a static capability: `try_snapshot` latches
275/// off only when the logic reports "no snapshot" *before it has ever
276/// published one* (the default `snapshot_into` returning false), so a
277/// non-opt-in plugin stops paying after one block. Once a plugin has
278/// published, it stays subscribed for its lifetime - a plugin that
279/// returns true then later false is violating the contract, and we keep
280/// calling it rather than silently latching off and serving stale bytes.
281/// Never blocks: `SnapshotSlot::publish` skips on reader contention, in
282/// which case the closure doesn't run and the latch is left alone.
283pub(crate) fn publish_snapshot<S, L>(
284 state: &L::DspState,
285 slot: &SnapshotSlot,
286 try_snapshot: &mut bool,
287 last_version: &mut Option<u64>,
288) where
289 S: Sample,
290 L: PluginLogicCore<S>,
291{
292 let version = L::snapshot_version(state);
293 publish_snapshot_with(slot, try_snapshot, last_version, version, |buf| {
294 L::snapshot_into(state, buf)
295 });
296}
297
298/// Latch logic behind [`publish_snapshot`], parameterized over the raw
299/// `snapshot_into` closure so it can be unit-tested without a full
300/// `PluginLogicCore` mock. `pub(crate)` so `HotShell` can drive it with
301/// a closure over the reloadable dylib's `truce_snapshot_into` symbol.
302///
303/// `version` is the plugin's [`PluginLogicCore::snapshot_version`] this
304/// block. When it's `Some(v)` and equals `*last_version`, the state is
305/// unchanged since the last landed publish, so the whole publish is
306/// skipped - no lock, no copy, O(1). `None` re-serializes every block
307/// (the historical behavior). `last_version` advances only on a landed
308/// write, so a block skipped on reader contention retries next time.
309pub(crate) fn publish_snapshot_with(
310 slot: &SnapshotSlot,
311 try_snapshot: &mut bool,
312 last_version: &mut Option<u64>,
313 version: Option<u64>,
314 snapshot_into: impl FnOnce(&mut Vec<u8>) -> bool,
315) {
316 if !*try_snapshot {
317 return;
318 }
319 // Version gate: a versioned plugin whose token is unchanged since the
320 // last landed publish keeps the previous snapshot - the common path.
321 if let Some(v) = version
322 && *last_version == Some(v)
323 {
324 return;
325 }
326 let ran_unsupported = std::cell::Cell::new(false);
327 let landed = slot.publish(|buf| {
328 let wrote = snapshot_into(buf);
329 ran_unsupported.set(!wrote);
330 wrote
331 });
332 // Record the version only on a landed real write, so a block skipped
333 // on reader contention retries next time instead of latching a
334 // version whose bytes never reached the slot.
335 if landed && !ran_unsupported.get() {
336 *last_version = version;
337 }
338 // First-block opt-out only: a plugin that has already published is
339 // committed for its lifetime, so a later false never latches us off.
340 if ran_unsupported.get() && !slot.is_supported() {
341 *try_snapshot = false;
342 }
343}
344
345// ---------------------------------------------------------------------------
346// export_static! macro
347// ---------------------------------------------------------------------------
348
349/// Compile-time static embedding of a `PluginLogic` impl into the binary.
350///
351/// Produces a `__HotShellWrapper` struct that implements `Plugin + PluginExport`,
352/// so format export macros (`export_clap!`, `export_vst3!`, etc.) work unchanged.
353/// No dlopen, no file watcher, zero runtime overhead. Bus layouts come from
354/// `<$logic as PluginLogic>::bus_layouts()` - override the trait method to
355/// pick something other than the stereo default.
356///
357/// ```ignore
358/// export_static! {
359/// params: GainParams,
360/// info: plugin_info!(...),
361/// logic: Gain,
362/// }
363///
364/// #[cfg(feature = "clap")]
365/// truce_clap::export_clap!(__HotShellWrapper);
366/// ```
367#[macro_export]
368macro_rules! export_static {
369 (
370 params: $params:ty,
371 info: $info:expr,
372 logic: $logic:ty,
373 $(tasks: [$($task:ty),+],)?
374 ) => {
375 pub struct __HotShellWrapper {
376 // `Sample` here resolves to the type alias the user
377 // imported from a prelude (`prelude` / `prelude32` →
378 // `f32`; `prelude64` → `f64`; `prelude64m` → `f32`). The
379 // `PluginLogic<Sample>` bound on the user's impl must
380 // match this, so the prelude is what picks the audio
381 // buffer precision end-to-end.
382 inner: $crate::static_shell::StaticShell<$params, $logic, Sample>,
383 }
384
385 impl $crate::__macro_deps::truce_core::plugin::PluginRuntime for __HotShellWrapper {
386 type Sample = Sample;
387
388 fn supports_in_place() -> bool
389 where
390 Self: Sized,
391 {
392 // `PluginLogicCore<Sample>` is the wrapper-facing
393 // trait; the user impl'd one of the leaf traits
394 // (`PluginLogic` / `PluginLogic64`), and the blanket
395 // bridge defined alongside those traits in
396 // `truce-plugin` makes them also satisfy
397 // `PluginLogicCore<Sample>` automatically. Sample
398 // resolves through the prelude alias in scope at the
399 // macro call site.
400 <$logic as $crate::__macro_deps::truce_plugin::PluginLogicCore<Sample>>::supports_in_place()
401 }
402
403 fn info() -> $crate::__macro_deps::truce_core::info::PluginInfo
404 where
405 Self: Sized,
406 {
407 $info
408 }
409
410 fn bus_layouts() -> Vec<$crate::__macro_deps::truce_core::bus::BusLayout>
411 where
412 Self: Sized,
413 {
414 <$logic as $crate::__macro_deps::truce_plugin::PluginLogicCore<Sample>>::bus_layouts()
415 }
416
417 fn init(&mut self) {
418 self.inner.init();
419 }
420
421 fn reset(&mut self, config: &$crate::__macro_deps::truce_core::config::AudioConfig) {
422 self.inner.reset(config);
423 }
424
425 fn process(
426 &mut self,
427 buffer: &mut $crate::__macro_deps::truce_core::buffer::AudioBuffer<Sample>,
428 events: &$crate::__macro_deps::truce_core::events::EventList,
429 context: &mut $crate::__macro_deps::truce_core::process::ProcessContext,
430 ) -> $crate::__macro_deps::truce_core::process::ProcessStatus {
431 self.inner.process(buffer, events, context)
432 }
433
434 fn save_state(&self) -> Vec<u8> {
435 self.inner.save_state()
436 }
437
438 fn snapshot_into(&self, buf: &mut Vec<u8>) -> bool {
439 self.inner.snapshot_into(buf)
440 }
441
442 fn load_state(
443 &mut self,
444 data: &[u8],
445 ) -> Result<(), $crate::__macro_deps::truce_core::state::StateLoadError> {
446 self.inner.load_state(data)
447 }
448
449 fn republish_snapshot(&mut self) {
450 self.inner.republish_snapshot();
451 }
452
453 fn migrate_state(
454 foreign: &$crate::__macro_deps::truce_core::state::ForeignState,
455 ) -> Option<$crate::__macro_deps::truce_core::state::MigratedState>
456 where
457 Self: Sized,
458 {
459 <$logic as $crate::__macro_deps::truce_plugin::PluginLogicCore<Sample>>::migrate_state(foreign)
460 }
461
462 fn latency(&self) -> u32 {
463 self.inner.latency()
464 }
465 fn tail(&self) -> u32 {
466 self.inner.tail()
467 }
468 fn get_meter(&self, meter_id: u32) -> f32 {
469 self.inner.get_meter(meter_id)
470 }
471 }
472
473 impl $crate::__macro_deps::truce_core::export::PluginExport for __HotShellWrapper {
474 type Params = $params;
475
476 fn create() -> Self {
477 let params = std::sync::Arc::new(<$params>::new());
478 // Each `tasks: [..]` type gets its own lane (queue + mode).
479 // The bundle collapses to `None` when no types were listed,
480 // so a plugin with no tasks runs with no pool.
481 #[allow(unused_mut)]
482 let mut __task_bundle =
483 $crate::__macro_deps::truce_core::tasks::TaskSpawnerBundle::new();
484 $(
485 $({
486 let __task_run = {
487 let params = std::sync::Arc::clone(¶ms);
488 move |task| {
489 <$task as $crate::__macro_deps::truce_plugin::BackgroundTask>::run(
490 task, ¶ms,
491 )
492 }
493 };
494 // `SERIALIZED` picks one-slot vs concurrent draining
495 // for this lane; the const folds the branch at
496 // compile time.
497 let __spawner = if <$task as $crate::__macro_deps::truce_plugin::BackgroundTask>::SERIALIZED {
498 $crate::__macro_deps::truce_core::tasks::TaskSpawner::<$task>::new_serialized(__task_run)
499 } else {
500 $crate::__macro_deps::truce_core::tasks::TaskSpawner::<$task>::new(__task_run)
501 };
502 __task_bundle.push(__spawner);
503 })+
504 )?
505 let tasks = __task_bundle.into_any();
506 // The descriptor `$logic` is stateless; `from_parts`
507 // builds the DSP state via `<$logic>::init(¶ms, &cx)`.
508 Self {
509 inner: $crate::static_shell::StaticShell::from_parts(params, tasks),
510 }
511 }
512
513 fn params(&self) -> &$params {
514 &self.inner.params
515 }
516
517 fn params_arc(&self) -> std::sync::Arc<$params> {
518 std::sync::Arc::clone(&self.inner.params)
519 }
520
521 fn meter_store(
522 &self,
523 ) -> std::sync::Arc<$crate::__macro_deps::truce_core::meters::MeterStore> {
524 self.inner.meter_store()
525 }
526
527 fn snapshot_slot(
528 &self,
529 ) -> std::sync::Arc<$crate::__macro_deps::truce_core::snapshot::SnapshotSlot> {
530 self.inner.snapshot_slot()
531 }
532
533 fn task_spawner(
534 &self,
535 ) -> ::core::option::Option<
536 $crate::__macro_deps::truce_core::tasks::AnyTaskSpawner,
537 > {
538 self.inner.task_spawner()
539 }
540
541 fn editor_builder(
542 &self,
543 ) -> $crate::__macro_deps::truce_core::editor::EditorBuilder<$params> {
544 // Builds from the lock-free param store, never the
545 // embedded logic - the audio thread's `&mut logic` is
546 // irrelevant here, so opening the editor takes no lock.
547 Box::new(|params| {
548 Some(
549 <$logic as $crate::__macro_deps::truce_plugin::PluginEditor<Sample>>::editor(
550 params,
551 ),
552 )
553 })
554 }
555 }
556 };
557}
558
559#[cfg(test)]
560mod tests {
561 use super::publish_snapshot_with;
562 use std::cell::Cell;
563 use truce_core::snapshot::SnapshotSlot;
564
565 #[test]
566 fn non_opt_in_latches_off_on_first_block() {
567 let slot = SnapshotSlot::new();
568 let mut try_snapshot = true;
569 let mut last = None;
570
571 // Default `snapshot_into` (returns false) before any publish:
572 // latch off so we stop paying every block.
573 publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |_| false);
574 assert!(!try_snapshot, "first false must latch off");
575 assert!(!slot.is_supported());
576
577 // Subsequent blocks short-circuit and never call the closure.
578 let mut called = false;
579 publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |_| {
580 called = true;
581 false
582 });
583 assert!(!called, "latched-off slot must not call snapshot_into");
584 }
585
586 #[test]
587 fn opt_in_then_contract_violation_stays_subscribed() {
588 let slot = SnapshotSlot::new();
589 let mut try_snapshot = true;
590 let mut last = None;
591
592 // Block 1: plugin publishes - it has opted in for its lifetime.
593 publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |buf| {
594 buf.clear();
595 buf.extend_from_slice(&[1, 2, 3]);
596 true
597 });
598 assert!(try_snapshot);
599 assert!(slot.is_supported());
600 assert_eq!(slot.read(), Some(vec![1, 2, 3]));
601
602 // Block 2: a contract-violating false must NOT latch us off - we
603 // keep calling the plugin rather than silently going dark.
604 publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |_| false);
605 assert!(try_snapshot, "a post-opt-in false must not latch off");
606
607 // Block 3: still subscribed, so a fresh publish still lands.
608 publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |buf| {
609 buf.clear();
610 buf.extend_from_slice(&[4]);
611 true
612 });
613 assert_eq!(slot.read(), Some(vec![4]));
614 }
615
616 #[test]
617 fn unchanged_version_skips_the_copy() {
618 let slot = SnapshotSlot::new();
619 let mut try_snapshot = true;
620 let mut last = None;
621 let calls = Cell::new(0);
622 let publish = |ver, try_s: &mut bool, last: &mut Option<u64>| {
623 publish_snapshot_with(&slot, try_s, last, Some(ver), |buf| {
624 calls.set(calls.get() + 1);
625 buf.extend_from_slice(&[u8::try_from(ver).unwrap_or(0)]);
626 true
627 });
628 };
629
630 // Version 7 lands and is recorded.
631 publish(7, &mut try_snapshot, &mut last);
632 assert_eq!(calls.get(), 1);
633 assert_eq!(last, Some(7));
634 assert_eq!(slot.read(), Some(vec![7]));
635
636 // Same version twice more: the writer never runs again.
637 publish(7, &mut try_snapshot, &mut last);
638 publish(7, &mut try_snapshot, &mut last);
639 assert_eq!(calls.get(), 1, "unchanged version must skip the copy");
640
641 // A new version re-serializes.
642 publish(9, &mut try_snapshot, &mut last);
643 assert_eq!(calls.get(), 2);
644 assert_eq!(slot.read(), Some(vec![9]));
645 }
646
647 #[test]
648 fn unversioned_none_publishes_every_block() {
649 // The default (no `snapshot_version`) must keep re-serializing
650 // every block - the historical behavior, unchanged.
651 let slot = SnapshotSlot::new();
652 let mut try_snapshot = true;
653 let mut last = None;
654 let calls = Cell::new(0);
655 for _ in 0..3 {
656 publish_snapshot_with(&slot, &mut try_snapshot, &mut last, None, |buf| {
657 calls.set(calls.get() + 1);
658 buf.push(1);
659 true
660 });
661 }
662 assert_eq!(calls.get(), 3, "None version re-serializes every block");
663 }
664
665 #[test]
666 fn load_reset_forces_republish_at_unchanged_version() {
667 // A load clears `last_snapshot_version` in both shells' `load_state`,
668 // so the `republish_snapshot` the wrapper fires right after a load
669 // re-serializes even though the plugin's version token didn't change
670 // (a counter that round-trips through the blob, or an author who
671 // forgot to bump). Without the reset the gate stays closed and the
672 // slot keeps pre-load bytes - the host's next save reverts the load.
673 let slot = SnapshotSlot::new();
674 let mut try_snapshot = true;
675 let mut last = None;
676 let calls = Cell::new(0);
677 let publish = |ver, try_s: &mut bool, last: &mut Option<u64>| {
678 publish_snapshot_with(&slot, try_s, last, Some(ver), |buf| {
679 calls.set(calls.get() + 1);
680 buf.push(u8::try_from(ver).unwrap_or(0));
681 true
682 });
683 };
684
685 publish(5, &mut try_snapshot, &mut last);
686 publish(5, &mut try_snapshot, &mut last);
687 assert_eq!(calls.get(), 1, "unchanged version skips");
688
689 // `load_state` clears the gate.
690 last = None;
691 publish(5, &mut try_snapshot, &mut last);
692 assert_eq!(
693 calls.get(),
694 2,
695 "a republish after a load must re-serialize even at the same version"
696 );
697 }
698}