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::PluginLogicCore;
use crate::canary::{AbiCanary, verify_probe};
use truce_params::sample::Sample;
type ProbeFn<S> = fn() -> Box<dyn PluginLogicCore<S>>;
type CreateFn<S> = fn(*const ()) -> Box<dyn PluginLogicCore<S>>;
struct Candidate<S: Sample> {
library: Library,
plugin: Box<dyn PluginLogicCore<S>>,
hash: u32,
mtime: SystemTime,
temp_path: PathBuf,
}
pub struct NativeLoader<S: Sample = f32> {
dylib_path: PathBuf,
library: Option<Library>,
plugin: Option<Box<dyn PluginLogicCore<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,
plugin: 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") } {
Ok(f) => f,
Err(e) => {
log::warn!("missing truce_abi_canary export: {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 probe_fn: Symbol<ProbeFn<S>> = match unsafe { lib.get(b"truce_vtable_probe") } {
Ok(f) => f,
Err(e) => {
log::warn!("missing truce_vtable_probe export: {e}");
cleanup_temp(lib, &temp);
return None;
}
};
let mut probe = probe_fn();
let probe_result = verify_probe(probe.as_mut());
drop(probe);
if let Err(msg) = probe_result {
log::error!("vtable probe failed: {msg}");
cleanup_temp(lib, &temp);
return None;
}
let create_fn: Symbol<CreateFn<S>> = match unsafe { lib.get(b"truce_create") } {
Ok(f) => f,
Err(e) => {
log::warn!("missing truce_create export: {e}");
cleanup_temp(lib, &temp);
return None;
}
};
let plugin = create_fn(self.params_ptr);
Some(Candidate {
library: lib,
plugin,
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.plugin = Some(cand.plugin);
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 plugin 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 plugin loaded");
return false;
};
let state = self.plugin.as_ref().map(|p| p.save_state());
self.plugin = None;
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.plugin = Some(candidate.plugin);
self.last_hash = candidate.hash;
self.last_modified = candidate.mtime;
self.current_temp = Some(candidate.temp_path);
if let (Some(state), Some(plugin)) = (state, self.plugin.as_mut())
&& !state.is_empty()
{
if let Err(e) = plugin.load_state(&state) {
log::warn!("hot-reload: new dylib rejected previous state ({e}); keeping defaults");
}
plugin.state_changed();
}
log::info!(
"hot-reload complete (load #{}, {} leaked handles)",
self.load_counter,
self.leaked_handles.len()
);
true
}
#[must_use]
pub fn plugin(&self) -> Option<&dyn PluginLogicCore<S>> {
self.plugin.as_ref().map(std::convert::AsRef::as_ref)
}
pub fn plugin_mut(&mut self) -> Option<&mut dyn PluginLogicCore<S>> {
self.plugin.as_mut().map(std::convert::AsMut::as_mut)
}
#[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.plugin = 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())
}