Skip to main content

tellur_live/
plugin.rs

1//! Hot-reload loader for timeline plugins.
2//!
3//! Loads the `cdylib` a project compiles to (via `dlopen`), resolves the
4//! [`tellur_plugin::ENTRY_SYMBOL`] entry point, and swaps in a fresh
5//! [`TimelineCollection`] when the source library changes on disk. The plugin
6//! ABI itself — the entry symbol and the collection trait — lives in
7//! `tellur-plugin`; this module is only the host side that consumes it.
8
9use std::error::Error;
10use std::ffi::{CStr, CString};
11use std::fmt;
12use std::fs;
13use std::io::Read;
14use std::os::raw::{c_char, c_int, c_void};
15#[cfg(unix)]
16use std::os::unix::fs::MetadataExt;
17use std::path::{Path, PathBuf};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use tellur_plugin::{
21    validate_plugin_fingerprint, AbiFingerprintFn, AbiMismatchError, EntryFn, TimelineCollection,
22    ABI_FINGERPRINT_SYMBOL, ENTRY_SYMBOL,
23};
24
25pub enum PluginLoadError {
26    Io(std::io::Error),
27    InvalidPath(PathBuf),
28    Open { path: PathBuf, message: String },
29    Symbol { symbol: String, message: String },
30    MissingAbiFingerprint,
31    AbiMismatch(AbiMismatchError),
32    MissingPlugin,
33}
34
35impl fmt::Debug for PluginLoadError {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        fmt::Display::fmt(self, f)
38    }
39}
40
41impl fmt::Display for PluginLoadError {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            Self::Io(e) => write!(f, "{e}"),
45            Self::InvalidPath(path) => write!(f, "invalid plugin path: {}", path.display()),
46            Self::Open { path, message } => {
47                write!(f, "failed to open plugin {}: {message}", path.display())
48            }
49            Self::Symbol { symbol, message } => {
50                write!(f, "failed to load symbol {symbol}: {message}")
51            }
52            Self::MissingAbiFingerprint => write!(
53                f,
54                "plugin is missing the ABI fingerprint symbol (tellur_abi_fingerprint_v1); \
55                 rebuild the project with a tellur version that exports it"
56            ),
57            Self::AbiMismatch(err) => write!(f, "{err}"),
58            Self::MissingPlugin => write!(f, "plugin is not loaded"),
59        }
60    }
61}
62
63impl Error for PluginLoadError {
64    fn source(&self) -> Option<&(dyn Error + 'static)> {
65        match self {
66            Self::Io(e) => Some(e),
67            _ => None,
68        }
69    }
70}
71
72impl From<std::io::Error> for PluginLoadError {
73    fn from(value: std::io::Error) -> Self {
74        Self::Io(value)
75    }
76}
77
78#[derive(Clone, Copy, PartialEq, Eq)]
79struct SourceStamp {
80    modified: SystemTime,
81    len: u64,
82    changed: Option<(i64, i64)>,
83    hash: u64,
84}
85
86impl SourceStamp {
87    fn cache_key(self) -> String {
88        format!("{:016x}-{:x}", self.hash, self.len)
89    }
90
91    fn same_content(self, other: Self) -> bool {
92        self.len == other.len && self.hash == other.hash
93    }
94
95    fn same_file_state(self, modified: SystemTime, len: u64, changed: Option<(i64, i64)>) -> bool {
96        self.modified == modified && self.len == len && self.changed == changed
97    }
98}
99
100struct LoadedPlugin {
101    stamp: SourceStamp,
102    cache_key: String,
103    staged_path: PathBuf,
104    collection: Box<dyn TimelineCollection>,
105    library: DynamicLibrary,
106}
107
108/// Maintains one loaded timeline plugin and reloads it when the source
109/// library changes on disk.
110pub struct HotReloadPlugin {
111    source_path: PathBuf,
112    loaded: Option<LoadedPlugin>,
113    retired_libraries: Vec<DynamicLibrary>,
114    last_error: Option<String>,
115    failed_stamp: Option<SourceStamp>,
116}
117
118impl HotReloadPlugin {
119    pub fn new(source_path: impl Into<PathBuf>) -> Self {
120        Self {
121            source_path: source_path.into(),
122            loaded: None,
123            retired_libraries: Vec::new(),
124            last_error: None,
125            failed_stamp: None,
126        }
127    }
128
129    pub fn source_path(&self) -> &Path {
130        &self.source_path
131    }
132
133    pub fn staged_path(&self) -> Option<&Path> {
134        self.loaded
135            .as_ref()
136            .map(|loaded| loaded.staged_path.as_path())
137    }
138
139    pub fn cache_key(&self) -> Option<&str> {
140        self.loaded.as_ref().map(|loaded| loaded.cache_key.as_str())
141    }
142
143    pub fn last_error(&self) -> Option<&str> {
144        self.last_error.as_deref()
145    }
146
147    pub fn reload_if_changed(&mut self) -> Result<bool, PluginLoadError> {
148        let metadata = fs::metadata(&self.source_path)?;
149        let modified = metadata.modified()?;
150        let len = metadata.len();
151        let changed = metadata_change_time(&metadata);
152        if let Some(loaded) = &self.loaded {
153            if loaded.stamp.same_file_state(modified, len, changed) {
154                return Ok(false);
155            }
156            if self
157                .failed_stamp
158                .is_some_and(|stamp| stamp.same_file_state(modified, len, changed))
159            {
160                return Ok(false);
161            }
162        }
163
164        let stamp = SourceStamp {
165            modified,
166            len,
167            changed,
168            hash: file_hash(&self.source_path)?,
169        };
170        if let Some(loaded) = self.loaded.as_mut() {
171            if loaded.stamp.same_content(stamp) {
172                loaded.stamp = stamp;
173                self.failed_stamp = None;
174                self.last_error = None;
175                return Ok(false);
176            }
177        }
178
179        match load_plugin(&self.source_path, stamp) {
180            Ok(next) => {
181                if let Some(previous) = self.loaded.replace(next) {
182                    drop(previous.collection);
183                    self.retired_libraries.push(previous.library);
184                }
185                self.last_error = None;
186                self.failed_stamp = None;
187                Ok(true)
188            }
189            Err(e) if self.loaded.is_some() => {
190                eprintln!("{e}");
191                self.last_error = Some(e.to_string());
192                self.failed_stamp = Some(stamp);
193                Ok(false)
194            }
195            Err(e) => Err(e),
196        }
197    }
198
199    pub fn collection(&self) -> Result<&dyn TimelineCollection, PluginLoadError> {
200        self.loaded
201            .as_ref()
202            .map(|loaded| loaded.collection.as_ref())
203            .ok_or(PluginLoadError::MissingPlugin)
204    }
205}
206
207fn metadata_change_time(metadata: &fs::Metadata) -> Option<(i64, i64)> {
208    #[cfg(unix)]
209    {
210        Some((metadata.ctime(), metadata.ctime_nsec()))
211    }
212
213    #[cfg(not(unix))]
214    {
215        let _ = metadata;
216        None
217    }
218}
219
220fn load_plugin(path: &Path, stamp: SourceStamp) -> Result<LoadedPlugin, PluginLoadError> {
221    let staged_path = stage_library(path, stamp)?;
222    let library = unsafe { DynamicLibrary::open(&staged_path)? };
223    check_plugin_abi(&library)?;
224    let entry: EntryFn = unsafe { library.symbol(ENTRY_SYMBOL)? };
225    let collection = unsafe { entry() };
226    Ok(LoadedPlugin {
227        stamp,
228        cache_key: stamp.cache_key(),
229        staged_path,
230        collection,
231        library,
232    })
233}
234
235fn check_plugin_abi(library: &DynamicLibrary) -> Result<(), PluginLoadError> {
236    if std::env::var_os("TELLUR_SKIP_ABI_CHECK").is_some() {
237        eprintln!("warning: TELLUR_SKIP_ABI_CHECK=1; skipping plugin ABI fingerprint check");
238        return Ok(());
239    }
240
241    let fingerprint_fn: AbiFingerprintFn =
242        match unsafe { library.symbol(ABI_FINGERPRINT_SYMBOL) } {
243            Ok(f) => f,
244            Err(PluginLoadError::Symbol { symbol, .. })
245                if symbol == "tellur_abi_fingerprint_v1" =>
246            {
247                return Err(PluginLoadError::MissingAbiFingerprint);
248            }
249            Err(e) => return Err(e),
250        };
251
252    let plugin_ptr = unsafe { fingerprint_fn() };
253    if plugin_ptr.is_null() {
254        return Err(PluginLoadError::Symbol {
255            symbol: "tellur_abi_fingerprint_v1".to_owned(),
256            message: "fingerprint function returned a null pointer".to_owned(),
257        });
258    }
259
260    let plugin_fp = unsafe { CStr::from_ptr(plugin_ptr) }
261        .to_str()
262        .map_err(|_| PluginLoadError::Symbol {
263            symbol: "tellur_abi_fingerprint_v1".to_owned(),
264            message: "fingerprint is not valid UTF-8".to_owned(),
265        })?;
266
267    validate_plugin_fingerprint(plugin_fp).map_err(PluginLoadError::AbiMismatch)
268}
269
270fn stage_library(path: &Path, stamp: SourceStamp) -> Result<PathBuf, PluginLoadError> {
271    let file_name = path
272        .file_name()
273        .and_then(|s| s.to_str())
274        .ok_or_else(|| PluginLoadError::InvalidPath(path.to_owned()))?;
275    let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("so");
276    let stem = file_name
277        .strip_suffix(&format!(".{ext}"))
278        .unwrap_or(file_name);
279    let modified = stamp
280        .modified
281        .duration_since(UNIX_EPOCH)
282        .unwrap_or_default()
283        .as_nanos();
284    let dir = std::env::temp_dir().join("tellur-live");
285    fs::create_dir_all(&dir)?;
286    let staged = dir.join(format!(
287        "{stem}-{modified}-{}-{:016x}.{ext}",
288        stamp.len, stamp.hash
289    ));
290    fs::copy(path, &staged)?;
291    Ok(staged)
292}
293
294fn file_hash(path: &Path) -> Result<u64, PluginLoadError> {
295    let mut file = fs::File::open(path)?;
296    let mut hash = FNV_OFFSET;
297    let mut buf = [0u8; 64 * 1024];
298    loop {
299        let n = file.read(&mut buf)?;
300        if n == 0 {
301            break;
302        }
303        for byte in &buf[..n] {
304            hash ^= u64::from(*byte);
305            hash = hash.wrapping_mul(FNV_PRIME);
306        }
307    }
308    Ok(hash)
309}
310
311const FNV_OFFSET: u64 = 0xcbf29ce484222325;
312const FNV_PRIME: u64 = 0x100000001b3;
313
314struct DynamicLibrary {
315    handle: *mut c_void,
316}
317
318unsafe impl Send for DynamicLibrary {}
319
320impl DynamicLibrary {
321    unsafe fn open(path: &Path) -> Result<Self, PluginLoadError> {
322        let c_path = CString::new(path.as_os_str().to_string_lossy().as_bytes())
323            .map_err(|_| PluginLoadError::InvalidPath(path.to_owned()))?;
324        clear_dlerror();
325        let handle = dlopen(c_path.as_ptr(), RTLD_NOW | RTLD_LOCAL);
326        if handle.is_null() {
327            return Err(PluginLoadError::Open {
328                path: path.to_owned(),
329                message: dlerror_message(),
330            });
331        }
332        Ok(Self { handle })
333    }
334
335    unsafe fn symbol<T>(&self, symbol: &[u8]) -> Result<T, PluginLoadError> {
336        clear_dlerror();
337        let ptr = dlsym(self.handle, symbol.as_ptr().cast());
338        if ptr.is_null() {
339            return Err(PluginLoadError::Symbol {
340                symbol: String::from_utf8_lossy(symbol)
341                    .trim_end_matches('\0')
342                    .to_owned(),
343                message: dlerror_message(),
344            });
345        }
346        Ok(std::mem::transmute_copy::<*mut c_void, T>(&ptr))
347    }
348}
349
350impl Drop for DynamicLibrary {
351    fn drop(&mut self) {
352        unsafe {
353            dlclose(self.handle);
354        }
355    }
356}
357
358const RTLD_NOW: c_int = 2;
359const RTLD_LOCAL: c_int = 0;
360
361#[cfg(target_os = "linux")]
362#[link(name = "dl")]
363unsafe extern "C" {
364    fn dlopen(filename: *const c_char, flags: c_int) -> *mut c_void;
365    fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
366    fn dlclose(handle: *mut c_void) -> c_int;
367    fn dlerror() -> *const c_char;
368}
369
370#[cfg(not(target_os = "linux"))]
371unsafe extern "C" {
372    fn dlopen(filename: *const c_char, flags: c_int) -> *mut c_void;
373    fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
374    fn dlclose(handle: *mut c_void) -> c_int;
375    fn dlerror() -> *const c_char;
376}
377
378unsafe fn clear_dlerror() {
379    let _ = dlerror();
380}
381
382unsafe fn dlerror_message() -> String {
383    let err = dlerror();
384    if err.is_null() {
385        "unknown dynamic loader error".to_owned()
386    } else {
387        CStr::from_ptr(err).to_string_lossy().into_owned()
388    }
389}