use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Weak};
use std::time::{Duration, SystemTime};
use parking_lot::Mutex;
static LOADER_ID: AtomicU64 = AtomicU64::new(0);
use libloading::{Library, Symbol};
use crate::canary::AbiCanary;
use truce_core::buffer::AudioBuffer;
use truce_core::config::AudioConfig;
use truce_core::events::EventList;
use truce_core::process::{ProcessContext, ProcessStatus};
use truce_core::state::StateLoadError;
use truce_params::sample::Sample;
type ProcessFn<S> =
fn(*mut (), *const (), &mut AudioBuffer<S>, &EventList, &mut ProcessContext) -> ProcessStatus;
pub type StateDropFn = fn(*mut ());
struct LogicSymbols<S: Sample> {
init_state: fn(*const ()) -> *mut (),
drop_state: StateDropFn,
reset: fn(*mut (), *const (), &AudioConfig),
process: ProcessFn<S>,
latency: fn(*const ()) -> u32,
tail: fn(*const ()) -> u32,
save_state: fn(*const ()) -> Vec<u8>,
snapshot_into: fn(*const (), &mut Vec<u8>) -> bool,
load_state: fn(*mut (), &[u8]) -> Result<(), StateLoadError>,
state_changed: fn(*mut (), *const ()),
fingerprint: u64,
}
impl<S: Sample> LogicSymbols<S> {
unsafe fn resolve(lib: &Library) -> Option<Self> {
macro_rules! sym {
($name:literal, $ty:ty) => {{
let s: Symbol<$ty> = match unsafe { lib.get($name) } {
Ok(s) => s,
Err(e) => {
log::warn!(
"missing export {} (stale pre-flat-ABI dylib?): {e}",
std::str::from_utf8($name).unwrap_or("?")
);
return None;
}
};
*s
}};
}
let fingerprint_fn: fn() -> u64 = sym!(b"truce_state_fingerprint", fn() -> u64);
Some(Self {
init_state: sym!(b"truce_init_state", fn(*const ()) -> *mut ()),
drop_state: sym!(b"truce_drop_state", fn(*mut ())),
reset: sym!(b"truce_reset", fn(*mut (), *const (), &AudioConfig)),
process: sym!(b"truce_process", ProcessFn<S>),
latency: sym!(b"truce_latency", fn(*const ()) -> u32),
tail: sym!(b"truce_tail", fn(*const ()) -> u32),
save_state: sym!(b"truce_save_state", fn(*const ()) -> Vec<u8>),
snapshot_into: sym!(b"truce_snapshot_into", fn(*const (), &mut Vec<u8>) -> bool),
load_state: sym!(
b"truce_load_state",
fn(*mut (), &[u8]) -> Result<(), StateLoadError>
),
state_changed: sym!(b"truce_state_changed", fn(*mut (), *const ())),
fingerprint: fingerprint_fn(),
})
}
}
struct Candidate<S: Sample> {
library: Library,
symbols: LogicSymbols<S>,
hash: u32,
mtime: SystemTime,
temp_path: PathBuf,
}
pub struct NativeLoader<S: Sample = f32> {
dylib_path: PathBuf,
library: Option<Library>,
symbols: Option<LogicSymbols<S>>,
params_ptr: *const (),
last_modified: SystemTime,
last_hash: u32,
watcher_stop: Arc<AtomicBool>,
leaked_handles: Vec<Library>,
temp_paths: Vec<PathBuf>,
current_temp: Option<PathBuf>,
load_counter: u64,
instance_id: u64,
}
unsafe impl<S: Sample> Send for NativeLoader<S> {}
impl<S: Sample> NativeLoader<S> {
pub fn new(dylib_path: PathBuf, params_ptr: *const ()) -> Self {
let mut loader = Self {
dylib_path,
library: None,
symbols: None,
params_ptr,
last_modified: SystemTime::UNIX_EPOCH,
last_hash: 0,
watcher_stop: Arc::new(AtomicBool::new(false)),
leaked_handles: Vec::new(),
temp_paths: Vec::new(),
current_temp: None,
load_counter: 0,
instance_id: LOADER_ID.fetch_add(1, Ordering::Relaxed),
};
loader.load();
loader
}
pub fn spawn_watcher(loader: &Arc<Mutex<Self>>) {
let weak = Arc::downgrade(loader);
let (path, stop) = {
let guard = loader.lock();
(guard.dylib_path.clone(), guard.watcher_stop.clone())
};
std::thread::Builder::new()
.name("truce-hot-watcher".into())
.spawn(move || watch_loop::<S>(&path, &weak, &stop))
.ok();
}
fn build_candidate(&mut self, new_hash: u32) -> Option<Candidate<S>> {
let temp = match self.copy_versioned() {
Ok(p) => p,
Err(e) => {
log::warn!("failed to copy dylib: {e}");
return None;
}
};
#[cfg(target_os = "macos")]
if let Some(temp_str) = temp.to_str() {
let _ = std::process::Command::new("codesign")
.args(["--sign", "-", "--force", temp_str])
.output();
} else {
log::warn!(
"codesign skipped: temp dylib path is not valid UTF-8 ({}); \
dlopen will likely fail under SIP",
temp.display()
);
}
let lib = match unsafe { Library::new(&temp) } {
Ok(l) => l,
Err(e) => {
log::warn!("dlopen failed: {e}");
let _ = std::fs::remove_file(&temp);
return None;
}
};
let cleanup_temp = |lib: Library, temp: &std::path::Path| {
drop(lib);
let _ = std::fs::remove_file(temp);
};
let canary_fn: Symbol<fn() -> AbiCanary> = match unsafe { lib.get(b"truce_abi_canary_v2") }
{
Ok(f) => f,
Err(e) => {
log::warn!("missing truce_abi_canary_v2 export (stale pre-2.0 logic dylib?): {e}");
cleanup_temp(lib, &temp);
return None;
}
};
let dylib_canary = canary_fn();
let shell_canary = AbiCanary::current::<S>();
if !shell_canary.matches(&dylib_canary) {
log::error!(
"ABI mismatch - rebuild both shell and logic:\n{}",
shell_canary.diff_report(&dylib_canary)
);
cleanup_temp(lib, &temp);
return None;
}
let Some(symbols) = (unsafe { LogicSymbols::<S>::resolve(&lib) }) else {
cleanup_temp(lib, &temp);
return None;
};
Some(Candidate {
library: lib,
symbols,
hash: new_hash,
mtime: file_mtime(&self.dylib_path),
temp_path: temp,
})
}
fn load(&mut self) -> bool {
let Some(new_hash) = crc32_file(&self.dylib_path) else {
log::warn!(
"failed to hash dylib at {} (missing / unreadable / mid-write); skipping load",
self.dylib_path.display()
);
return false;
};
if new_hash == self.last_hash && self.library.is_some() {
log::debug!("dylib unchanged (CRC32 match), skipping reload");
return true;
}
match self.build_candidate(new_hash) {
Some(cand) => {
self.library = Some(cand.library);
self.symbols = Some(cand.symbols);
self.last_hash = cand.hash;
self.last_modified = cand.mtime;
self.current_temp = Some(cand.temp_path);
log::info!("loaded plugin dylib: {}", self.dylib_path.display());
true
}
None => false,
}
}
pub fn reload(&mut self) -> bool {
let Some(new_hash) = crc32_file(&self.dylib_path) else {
log::warn!(
"failed to hash dylib at {} (missing / unreadable / mid-write); keeping previous code loaded",
self.dylib_path.display()
);
return false;
};
if new_hash == self.last_hash && self.library.is_some() {
log::debug!("dylib unchanged (CRC32 match), skipping reload");
return true;
}
let Some(candidate) = self.build_candidate(new_hash) else {
log::warn!("hot-reload failed; keeping previous code loaded");
return false;
};
if let Some(old) = self.library.take() {
self.leaked_handles.push(old);
if let Some(p) = self.current_temp.take() {
self.temp_paths.push(p);
}
}
self.library = Some(candidate.library);
self.symbols = Some(candidate.symbols);
self.last_hash = candidate.hash;
self.last_modified = candidate.mtime;
self.current_temp = Some(candidate.temp_path);
log::info!(
"hot-reload complete (load #{}, {} leaked handles)",
self.load_counter,
self.leaked_handles.len()
);
true
}
#[must_use]
pub fn state_fingerprint(&self) -> Option<u64> {
self.symbols.as_ref().map(|s| s.fingerprint)
}
#[must_use]
pub fn init_state(&self) -> Option<(*mut (), u64, StateDropFn)> {
let s = self.symbols.as_ref()?;
Some(((s.init_state)(self.params_ptr), s.fingerprint, s.drop_state))
}
pub fn process(
&self,
state: *mut (),
buffer: &mut AudioBuffer<S>,
events: &EventList,
ctx: &mut ProcessContext,
) -> ProcessStatus {
match self.symbols.as_ref() {
Some(s) => (s.process)(state, self.params_ptr, buffer, events, ctx),
None => ProcessStatus::Normal,
}
}
pub fn reset(&self, state: *mut (), config: &AudioConfig) {
if let Some(s) = self.symbols.as_ref() {
(s.reset)(state, self.params_ptr, config);
}
}
#[must_use]
pub fn latency(&self, state: *const ()) -> u32 {
self.symbols.as_ref().map_or(0, |s| (s.latency)(state))
}
#[must_use]
pub fn tail(&self, state: *const ()) -> u32 {
self.symbols.as_ref().map_or(0, |s| (s.tail)(state))
}
#[must_use]
pub fn save_state(&self, state: *const ()) -> Vec<u8> {
self.symbols
.as_ref()
.map_or_else(Vec::new, |s| (s.save_state)(state))
}
pub fn snapshot_into(&self, state: *const (), buf: &mut Vec<u8>) -> bool {
self.symbols
.as_ref()
.is_some_and(|s| (s.snapshot_into)(state, buf))
}
pub fn load_state(&self, state: *mut (), data: &[u8]) -> Result<(), StateLoadError> {
match self.symbols.as_ref() {
Some(s) => {
let r = (s.load_state)(state, data);
(s.state_changed)(state, self.params_ptr);
r
}
None => Ok(()),
}
}
#[must_use]
pub fn build_editor(
&self,
params_ptr: *const (),
) -> Option<Box<dyn truce_core::editor::Editor>> {
type BuildEditorFn = fn(*const ()) -> Box<dyn truce_core::editor::Editor>;
let library = self.library.as_ref()?;
let build: Symbol<BuildEditorFn> = unsafe { library.get(b"truce_build_editor").ok()? };
Some(build(params_ptr))
}
#[must_use]
pub fn is_loaded(&self) -> bool {
self.symbols.is_some()
}
#[must_use]
pub fn load_counter(&self) -> u64 {
self.load_counter
}
fn copy_versioned(&mut self) -> Result<PathBuf, std::io::Error> {
self.load_counter += 1;
let ext = self
.dylib_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("dylib");
let stem = self
.dylib_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("plugin");
let temp = std::env::temp_dir().join(format!(
"truce-hot-{stem}-{}-{}.{ext}",
self.instance_id, self.load_counter
));
std::fs::copy(&self.dylib_path, &temp)?;
Ok(temp)
}
}
impl<S: Sample> Drop for NativeLoader<S> {
fn drop(&mut self) {
self.watcher_stop.store(true, Ordering::Relaxed);
self.symbols = None;
if let (Some(lib), Some(path)) = (self.library.take(), self.current_temp.take()) {
drop(lib);
let _ = std::fs::remove_file(&path);
}
}
}
fn watch_loop<S: Sample>(
path: &std::path::Path,
loader: &Weak<Mutex<NativeLoader<S>>>,
stop: &AtomicBool,
) {
const POLL_INTERVAL: Duration = Duration::from_millis(500);
const STOP_CHECK: Duration = Duration::from_millis(50);
const SETTLE: Duration = Duration::from_millis(200);
const LOCK_WAIT: Duration = Duration::from_millis(50);
#[allow(clippy::cast_possible_truncation)]
let chunks = (POLL_INTERVAL.as_millis() / STOP_CHECK.as_millis()) as u32;
#[allow(clippy::cast_possible_truncation)]
let settle_chunks = (SETTLE.as_millis() / STOP_CHECK.as_millis()) as u32;
let mut last_mtime = file_mtime(path);
while !stop.load(Ordering::Relaxed) {
for _ in 0..chunks {
std::thread::sleep(STOP_CHECK);
if stop.load(Ordering::Relaxed) {
return;
}
}
let mtime = file_mtime(path);
if mtime <= last_mtime {
continue;
}
for _ in 0..settle_chunks {
std::thread::sleep(STOP_CHECK);
if stop.load(Ordering::Relaxed) {
return;
}
}
last_mtime = file_mtime(path);
let Some(loader) = loader.upgrade() else {
return;
};
let Some(mut guard) = loader.try_lock_for(LOCK_WAIT) else {
continue;
};
guard.reload();
}
}
fn file_mtime(path: &std::path::Path) -> SystemTime {
std::fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH)
}
fn crc32_file(path: &std::path::Path) -> Option<u32> {
use std::io::Read;
let mut file = std::fs::File::open(path).ok()?;
let mut hasher = crc32fast::Hasher::new();
let mut buf = [0u8; 8 * 1024];
loop {
match file.read(&mut buf) {
Ok(0) => break,
Ok(n) => hasher.update(&buf[..n]),
Err(_) => return None,
}
}
Some(hasher.finalize())
}