truce-loader 4.1.0

Hot-reloadable plugin logic for truce (native ABI, dylib loading)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
//! Shell-side integration: the hot-reloadable `HotShell<P, S>`.
//!
//! `HotShell<P, S = f32>` implements truce-core's `Plugin` +
//! `PluginExport` traits, delegating all logic to the flat Rust-ABI
//! symbols the hot-reloadable dylib exports (`export_plugin!`), which
//! it drives over an opaque `state: *mut ()` it owns across reloads.
//! The user's plugin impls one of the leaf traits (`PluginLogic` for
//! `f32` or `PluginLogic64` for `f64`); the blanket bridges in
//! `truce-plugin` lift that into the `PluginLogicCore<S>` the dylib's
//! exported functions call through.
//!
//! `HotShell` is parameterised over `S` so a `prelude64` plugin
//! and its `S = f64` logic dylib can hot-reload too. The chosen
//! precision is stamped into `AbiCanary::sample_precision` at
//! build time, so loading a mismatched dylib fails the canary
//! check before the vtable is touched.

use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;

use parking_lot::Mutex;
use truce_core::buffer::AudioBuffer;
use truce_core::bus::BusLayout;
use truce_core::config::{AudioConfig, ProcessMode};
use truce_core::events::{EventBody, EventList};
use truce_core::info::PluginInfo;
use truce_core::plugin::PluginRuntime;
use truce_core::process::{ProcessContext, ProcessStatus};
use truce_params::Params;
use truce_params::sample::Sample;

use crate::loader::NativeLoader;

/// How long a GUI / main-thread call into the loader (editor open,
/// state save / load) waits before giving up and returning the
/// "loader busy" fallback. Sized to span a typical audio block
/// (≪ 50 ms) without dragging through a full hot-reload window
/// (codesign + dlopen + canary verify can run 100s of ms on a 5–20
/// MB dylib). Matches the watcher's own `LOCK_WAIT` so the two
/// sides of the mutex have the same patience.
const GUI_LOCK_WAIT: Duration = Duration::from_millis(50);

// ---------------------------------------------------------------------------
// HotShell - the Plugin implementation that delegates to the dylib
// ---------------------------------------------------------------------------

/// A hot-reloadable plugin shell.
///
/// `P` is the parameter type (owned by the shell, survives reload).
/// `S` is the plugin's sample type (defaults to `f32` - the host wire
/// format). A `prelude64` plugin needs `S = f64`; the precision is
/// embedded in `AbiCanary::sample_precision`, so loading an `f64`
/// logic dylib into an `f32` shell (or vice versa) fails the canary
/// check at load time rather than silently binding to a vtable whose
/// `process()` slot expects a different `AudioBuffer<S>`.
///
/// All plugin logic (DSP, GUI rendering, layout) is delegated to the
/// flat exported functions in the loaded dylib, called over the
/// shell-owned opaque `state` pointer.
pub struct HotShell<P: Params, S: Sample = f32> {
    pub params: Arc<P>,
    loader: Arc<Mutex<NativeLoader<S>>>,
    /// The plugin's DSP state, owned by the shell as an erased
    /// `Box<State>` so it can outlive a hot-reload. Null before the
    /// first successful load. Only ever touched while the loader lock is
    /// held (audio thread) or under the wrapper's serialize (state
    /// save / load), never concurrently.
    state: *mut (),
    /// Fingerprint of the dylib that produced `state`. Compared against
    /// a reloaded dylib's fingerprint to decide preservation.
    state_fingerprint: u64,
    /// `drop` function from the dylib that produced `state` - kept so
    /// the state is freed by the exact code that made it, even after a
    /// reload swaps in a different dylib.
    state_dropper: Option<fn(*mut ())>,
    /// Meter values written by DSP, read by GUI.
    meters: Arc<truce_core::meters::MeterStore>,
    /// Lock-free publish slot for `snapshot_into`-based state save.
    snapshots: Arc<truce_core::snapshot::SnapshotSlot>,
    /// Latches off if the loaded logic reports no snapshot before it ever
    /// publishes one; a logic that has published stays subscribed.
    try_snapshot: bool,
    sample_rate: f64,
    max_block_size: usize,
    /// Processing mode from the last `reset`. Replayed when the audio
    /// thread re-resets a freshly hot-swapped dylib so the new instance
    /// prepares for the same render mode.
    process_mode: ProcessMode,
    /// Last `load_counter` value the audio path observed. When the
    /// file watcher drives a reload, this lags behind
    /// `loader.load_counter()` until `process()` runs `plugin.reset()`
    /// to match the new instance.
    last_seen_load_counter: u64,
    /// Atomic snapshots of the plugin's most recent `latency()` /
    /// `tail()`. Updated by the audio thread on each `process()` so
    /// the host's main-thread queries don't block on the loader mutex.
    latency_cache: AtomicU32,
    tail_cache: AtomicU32,
}

// SAFETY: the load-bearing field is `state: *mut ()` - a raw pointer,
// so the compiler won't infer `Send` and this manual impl is required.
// It is an erased `Box<L::DspState>`, and `PluginLogicCore::DspState:
// Send`, so the pointee is `Send` and safe to move to another thread; the
// shell exclusively owns that allocation (it alone frees it, through
// the origin dylib's `state_dropper`). The pointer is only
// dereferenced while the shell holds `&mut self` / `&self`, and the
// format wrapper serializes `process` / `reset` / `save_state` /
// `load_state` behind its plugin lock, so no two threads ever touch
// `state` at once. `state_dropper` is a bare `fn` pointer (`Send`).
// The remaining fields are `Send` on their own: `Arc<P>` (`P: Sync` by
// the `Params` contract), `Arc<Mutex<NativeLoader<S>>>` (`Send + Sync`),
// the atomics, and the atomic-slot `MeterStore`.
unsafe impl<P: Params, S: Sample> Send for HotShell<P, S> {}

impl<P: Params + 'static, S: Sample> HotShell<P, S> {
    pub fn new(params: P, dylib_path: PathBuf) -> Self {
        let params = Arc::new(params);
        let params_ptr = Arc::as_ptr(&params).cast::<()>();
        let loader = NativeLoader::new(dylib_path, params_ptr);
        let initial_counter = loader.load_counter();
        // Allocate the initial DSP state from the freshly loaded dylib
        // (before wrapping the loader in the mutex - no contention yet).
        let (state, state_fingerprint, state_dropper) = loader
            .init_state()
            .map_or((std::ptr::null_mut(), 0, None), |(st, fp, d)| {
                (st, fp, Some(d))
            });
        let loader = Arc::new(Mutex::new(loader));
        // Drive reloads off the audio thread. The watcher polls the
        // dylib path and runs `reload()` itself when mtime advances.
        NativeLoader::spawn_watcher(&loader);
        Self {
            params,
            loader,
            state,
            state_fingerprint,
            state_dropper,
            meters: truce_core::meters::MeterStore::new(),
            snapshots: truce_core::snapshot::SnapshotSlot::new(),
            try_snapshot: true,
            sample_rate: 44100.0,
            max_block_size: 1024,
            process_mode: ProcessMode::Realtime,
            last_seen_load_counter: initial_counter,
            latency_cache: AtomicU32::new(0),
            tail_cache: AtomicU32::new(0),
        }
    }

    /// Ensure `self.state` is a live allocation from the current dylib,
    /// allocating it if the shell came up before any dylib was loaded.
    /// Returns `false` if nothing is loaded (nothing to run).
    fn ensure_state(&mut self, loader: &NativeLoader<S>) -> bool {
        if !self.state.is_null() {
            return true;
        }
        if let Some((st, fp, dropper)) = loader.init_state() {
            self.state = st;
            self.state_fingerprint = fp;
            self.state_dropper = Some(dropper);
            true
        } else {
            false
        }
    }

    /// Free the current state through the dylib that made it, if any.
    fn drop_state(&mut self) {
        if let (false, Some(dropper)) = (self.state.is_null(), self.state_dropper.take()) {
            dropper(self.state);
        }
        self.state = std::ptr::null_mut();
    }

    /// Shared meter storage handle - the GUI-thread-safe channel
    /// for meter reads (see `PluginExport::meter_store`).
    #[must_use]
    pub fn meter_store(&self) -> Arc<truce_core::meters::MeterStore> {
        Arc::clone(&self.meters)
    }

    /// Shared snapshot slot for lock-free state save (see
    /// `PluginExport::snapshot_slot`).
    #[must_use]
    pub fn snapshot_slot(&self) -> Arc<truce_core::snapshot::SnapshotSlot> {
        Arc::clone(&self.snapshots)
    }

    /// A lock-free editor builder that constructs from the *currently
    /// loaded* dylib (via its `truce_build_editor` symbol), so GUI edits
    /// hot-reload - the host picks up the new editor on the next close+
    /// open. The closure takes the shared params `Arc`, `try_lock_for`s
    /// the loader (the audio thread only `try_lock`s it, so this never
    /// stalls audio), and returns `None` during an in-flight reload -
    /// the host retries editor creation on a later idle tick.
    #[must_use]
    pub fn editor_builder(&self) -> truce_core::editor::EditorBuilder<P> {
        let loader = Arc::clone(&self.loader);
        Box::new(move |params: Arc<P>| {
            let params_ptr = Arc::as_ptr(&params).cast::<()>();
            let guard = loader.try_lock_for(GUI_LOCK_WAIT)?;
            guard.build_editor(params_ptr)
        })
    }
}

impl<P: Params + 'static, S: Sample> PluginRuntime for HotShell<P, S> {
    type Sample = S;

    fn info() -> PluginInfo
    where
        Self: Sized,
    {
        unreachable!("HotShell::info() should not be called statically")
    }

    fn bus_layouts() -> Vec<BusLayout>
    where
        Self: Sized,
    {
        unreachable!("HotShell::bus_layouts() should not be called statically")
    }

    fn init(&mut self) {}

    fn reset(&mut self, config: &AudioConfig) {
        self.sample_rate = config.sample_rate;
        self.max_block_size = config.max_block_size;
        self.process_mode = config.process_mode;
        // Params plumbing is the shell's job, not the plugin's: settle
        // smoother coefficients and state before the dylib's `reset` so
        // its body reads post-snap values. Runs even when the loader
        // lock below is contended - params live host-side.
        self.params.set_sample_rate(config.sample_rate);
        self.params.snap_smoothers();

        // CLAP / VST3 may call `reset` on the audio thread; same
        // priority-inversion concern as `process`. The watcher's hold
        // window is bounded; if we miss this reset, the next
        // `process` call will still pick up the new sample rate via
        // the `last_seen_load_counter` path. So a missed reset here
        // is recoverable, while a blocked audio thread is not.
        // Lock through a cloned handle (one relaxed atomic inc, RT-safe)
        // so the guard borrows the local `Arc`, not `self` - leaving the
        // shell's state fields free to mutate under the lock.
        let loader_arc = Arc::clone(&self.loader);
        let Some(loader) = loader_arc.try_lock() else {
            return;
        };
        if self.ensure_state(&loader) {
            loader.reset(self.state, config);
            self.latency_cache
                .store(loader.latency(self.state), Ordering::Relaxed);
            self.tail_cache
                .store(loader.tail(self.state), Ordering::Relaxed);
        }
    }

    fn process(
        &mut self,
        buffer: &mut AudioBuffer<S>,
        events: &EventList,
        context: &mut ProcessContext,
    ) -> ProcessStatus {
        // Lock-free on the audio thread: if the watcher thread holds
        // the loader (reload in flight - codesign + dlopen + canary
        // probe takes 100s of ms on a 5–20 MB dylib), skip this block
        // rather than block. A skipped block is silent for one buffer
        // (`Normal` returns the host's already-zeroed output) - better
        // than parking the audio thread under priority inversion. The
        // watcher takes the lock briefly per reload (mtime poll loop's
        // `try_lock_for(50ms)`), so contention is bounded to the
        // reload window itself.
        // Lock through a cloned handle (RT-safe atomic inc) so the guard
        // borrows the local, not `self` - the state fields stay mutable.
        let loader_arc = Arc::clone(&self.loader);
        let Some(loader) = loader_arc.try_lock() else {
            return ProcessStatus::Normal;
        };

        // The watcher thread drives reload directly; the audio thread
        // observes the swap here and decides what happens to the live
        // DSP state.
        let counter = loader.load_counter();
        if counter != self.last_seen_load_counter {
            let config = AudioConfig::new(self.sample_rate, self.max_block_size)
                .with_process_mode(self.process_mode);
            match loader.state_fingerprint() {
                // Same `State` layout as our live state: the author
                // changed only code. Keep the allocation and run the new
                // `process` on it - the reverb tail keeps ringing, no
                // reset. This is the hot-reload payoff. `may_preserve`
                // rejects `NO_PRESERVE` (a stateless `()` state, or an
                // explicit opt-out) even against itself, so those re-init.
                Some(fp)
                    if !self.state.is_null()
                        && truce_core::dsp_state::may_preserve(fp, self.state_fingerprint) => {}
                // Different (or first) layout: the old bytes aren't a
                // valid new `State`. Drop them through the origin dylib,
                // allocate fresh from the new one, and reset.
                Some(_) => {
                    self.drop_state();
                    if self.ensure_state(&loader) {
                        loader.reset(self.state, &config);
                    }
                }
                // Nothing loaded now (failed reload): keep our state.
                None => {}
            }
            self.last_seen_load_counter = counter;
        }

        if !self.ensure_state(&loader) {
            return ProcessStatus::Normal;
        }

        // Apply parameter change events to our atomic params.
        // ParamChange values from format wrappers are PLAIN (already
        // denormalized). No smoother snap here: events set targets and
        // the smoothers ramp toward them; snapping belongs to `reset`
        // and state loads only.
        for e in events.iter() {
            if let EventBody::ParamChange { id, value } = &e.body {
                self.params.set_plain(*id, *value);
            }
        }

        // No sync needed - the dylib reads from the same Arc<Params>
        // via the shell's params pointer.

        // Build a ProcessContext with param/meter callbacks for the logic.
        let params = &self.params;
        let meters = &self.meters;
        let param_fn = |id: u32| -> f64 { params.get_plain(id).unwrap_or(0.0) };
        let meter_fn = |id: u32, v: f32| meters.write(id, v);
        let mut ctx = ProcessContext::new(
            context.transport,
            context.sample_rate,
            buffer.num_samples(),
            &mut *context.output_events,
        )
        .with_process_mode(context.process_mode)
        .with_params(&param_fn)
        .with_meters(&meter_fn);

        let status = loader.process(self.state, buffer, events, &mut ctx);

        let state = self.state.cast_const();
        crate::static_shell::publish_snapshot_with(
            &self.snapshots,
            &mut self.try_snapshot,
            |buf| loader.snapshot_into(state, buf),
        );

        // Refresh latency / tail caches so host-thread queries don't
        // have to take the loader lock.
        self.latency_cache
            .store(loader.latency(state), Ordering::Relaxed);
        self.tail_cache.store(loader.tail(state), Ordering::Relaxed);

        status
    }

    fn save_state(&self) -> Vec<u8> {
        // Hosts call this on the main / UI thread (e.g. project save,
        // preset capture). Bounded `try_lock_for` keeps a concurrent
        // hot-reload from hanging the host for the full reload window;
        // on miss the host receives an empty blob - same observable
        // shape as a plugin that has no extra state. Matches the host
        // contract better than a UI hang.
        let Some(loader) = self.loader.try_lock_for(GUI_LOCK_WAIT) else {
            return Vec::new();
        };
        if self.state.is_null() {
            return Vec::new();
        }
        loader.save_state(self.state.cast_const())
    }

    fn load_state(&mut self, data: &[u8]) -> Result<(), truce_core::state::StateLoadError> {
        // Same trade-off as `save_state`: bounded wait keeps the UI
        // thread from blocking through a reload. On timeout we report
        // success-with-no-op so the host doesn't surface a load
        // failure for what is effectively a reload race. If the host
        // load was carrying real preset bytes, the watcher's reload
        // will pull them back from the next user-driven preset
        // refresh; the alternative (UI hang) is worse.
        let Some(loader) = self.loader.try_lock_for(GUI_LOCK_WAIT) else {
            return Ok(());
        };
        if self.state.is_null() {
            return Ok(());
        }
        // The loader restores into `state` and fires `state_changed` in
        // the same window (so the next `process` sees refreshed caches),
        // matching the static shell's policy.
        loader.load_state(self.state, data)
    }

    fn migrate_state(
        _foreign: &truce_core::state::ForeignState,
    ) -> Option<truce_core::state::MigratedState>
    where
        Self: Sized,
    {
        // Receiverless: the logic type lives behind the loader's
        // `Box<dyn PluginLogicCore>` per instance, which an
        // associated function can't reach. Shell mode is a dev
        // configuration; legacy-state migration only runs in static
        // builds (the shape every shipped plugin uses).
        log::warn!(
            "truce-hot: host offered foreign state but --shell builds don't \
             route migrate_state; load will be reported as failed"
        );
        None
    }

    fn latency(&self) -> u32 {
        // Read the audio-thread-updated atomic snapshot rather than
        // dispatching through `&PluginLogic` (which would race with
        // the audio thread's `&mut PluginLogic` and require the
        // loader lock).
        self.latency_cache.load(Ordering::Relaxed)
    }

    fn tail(&self) -> u32 {
        self.tail_cache.load(Ordering::Relaxed)
    }

    fn get_meter(&self, meter_id: u32) -> f32 {
        self.meters.read(meter_id)
    }
}

impl<P: Params, S: Sample> Drop for HotShell<P, S> {
    fn drop(&mut self) {
        // Free the DSP state through the dylib that produced it (its
        // `Drop` glue lives there). The library is leaked, never closed,
        // so the drop function is still mapped. The loader's own `Drop`
        // then tears down the symbol table + leaked handles.
        self.drop_state();
    }
}

// Hot-reload is single-crate via `--features shell`, generated by
// `truce::plugin!` in `truce/src/plugin_macro.rs`. `HotShell<P>` is
// public-but-unadvertised because `__plugin_hot_reload!` wraps it via
// `truce::__reexport::HotShell`. The shell now hands the format
// wrapper whatever `PluginLogic::editor()` returns - no wrapper /
// watcher / hot-swap is mediated by truce-loader. Editor-side
// reload (swap-on-dylib-change while the window is open) is no
// longer supported; reopening the editor picks up the new build.