1use 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
108pub 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}