#![allow(unsafe_code)]
#![cfg(feature = "hot-reload")]
use std::collections::HashMap;
use std::panic::{self, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use libloading::Library;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddonInitResult {
pub name: String,
pub version: String,
pub description: Option<String>,
pub dependencies: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HotAddonManifest {
pub name: String,
pub version: String,
pub description: Option<String>,
pub file_path: PathBuf,
pub dependencies: Vec<String>,
pub loaded_at: chrono::DateTime<chrono::Utc>,
}
pub struct LoadedAddon {
pub manifest: HotAddonManifest,
_library: Arc<Library>,
}
#[derive(Debug, Error)]
pub enum HotReloadError {
#[error("动态库加载失败: {0}")]
LibraryLoad(#[from] libloading::Error),
#[error("找不到 addon_init 符号(插件未实现入口函数)")]
MissingInitSymbol,
#[error("addon_init 调用失败: {0}")]
InitFailed(String),
#[error("插件目录扫描失败: {0}")]
ScanFailed(String),
#[error("插件名称冲突: {0}")]
NameConflict(String),
#[error("插件依赖缺失: {0} 依赖 {1}(未加载)")]
MissingDependency(String, String),
}
pub type AddonScanResult = (String, Result<HotAddonManifest, HotReloadError>);
type AddonInitFn = extern "Rust" fn() -> AddonInitResult;
pub struct HotAddonLoader {
registry: Arc<RwLock<HashMap<String, LoadedAddon>>>,
scan_dirs: Vec<PathBuf>,
}
impl HotAddonLoader {
pub fn new() -> Self {
Self {
registry: Arc::new(RwLock::new(HashMap::new())),
scan_dirs: Vec::new(),
}
}
pub fn add_scan_dir(&mut self, dir: impl Into<PathBuf>) {
self.scan_dirs.push(dir.into());
}
pub fn loaded_addons(&self) -> Vec<String> {
self.registry.read().keys().cloned().collect()
}
pub fn get_manifest(&self, name: &str) -> Option<HotAddonManifest> {
self.registry
.read()
.get(name)
.map(|addon| addon.manifest.clone())
}
pub async fn scan(&mut self) -> Vec<AddonScanResult> {
let mut results = Vec::new();
let dirs = self.scan_dirs.clone();
for dir in dirs {
match self.scan_dir(&dir).await {
Ok(entries) => results.extend(entries),
Err(e) => results.push((
dir.to_string_lossy().to_string(),
Err(HotReloadError::ScanFailed(e.to_string())),
)),
}
}
results
}
async fn scan_dir(&mut self, dir: &Path) -> Result<Vec<AddonScanResult>, HotReloadError> {
let mut results = Vec::new();
let mut entries = tokio::fs::read_dir(dir).await.map_err(|e| {
HotReloadError::ScanFailed(format!("无法读取目录 {}: {}", dir.display(), e))
})?;
while let Some(entry) = entries.next_entry().await.map_err(|e| {
HotReloadError::ScanFailed(format!("读取目录条目失败 {}: {}", dir.display(), e))
})? {
let path = entry.path();
if !path.is_file() {
continue;
}
if !is_shared_library(&path) {
continue;
}
let name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
if self.registry.read().contains_key(&name) {
results.push((
name.clone(),
Err(HotReloadError::NameConflict(format!(
"插件 {} 已加载,跳过",
name
))),
));
continue;
}
match self.load_library(&path, &name) {
Ok(manifest) => results.push((name, Ok(manifest))),
Err(e) => results.push((name, Err(e))),
}
}
Ok(results)
}
fn load_library(&self, path: &Path, name: &str) -> Result<HotAddonManifest, HotReloadError> {
let library = unsafe { Library::new(path) }?;
let init_symbol: libloading::Symbol<AddonInitFn> = unsafe { library.get(b"addon_init\0") }
.map_err(|_| HotReloadError::MissingInitSymbol)?;
let init_result =
panic::catch_unwind(AssertUnwindSafe(*init_symbol)).map_err(|payload| {
let msg = if let Some(s) = payload.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"未知 panic(插件 addon_init 崩溃)".to_string()
};
HotReloadError::InitFailed(format!("插件 {} panic: {}", name, msg))
})?;
for dep in &init_result.dependencies {
if !self.registry.read().contains_key(dep) {
return Err(HotReloadError::MissingDependency(
init_result.name.clone(),
dep.clone(),
));
}
}
let manifest = HotAddonManifest {
name: init_result.name.clone(),
version: init_result.version.clone(),
description: init_result.description.clone(),
file_path: path.to_path_buf(),
dependencies: init_result.dependencies.clone(),
loaded_at: chrono::Utc::now(),
};
let loaded = LoadedAddon {
manifest: manifest.clone(),
_library: Arc::new(library),
};
self.registry.write().insert(name.to_string(), loaded);
Ok(manifest)
}
pub fn unload(&self, name: &str) -> Result<(), HotReloadError> {
if self.registry.write().remove(name).is_some() {
Ok(())
} else {
Err(HotReloadError::ScanFailed(format!("插件 {} 未加载", name)))
}
}
#[allow(dead_code)]
pub(crate) fn registry(&self) -> Arc<RwLock<HashMap<String, LoadedAddon>>> {
self.registry.clone()
}
}
impl Default for HotAddonLoader {
fn default() -> Self {
Self::new()
}
}
fn is_shared_library(path: &Path) -> bool {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if matches!(ext, "dylib" | "dll") {
return true;
}
if ext == "so" {
return true;
}
path.file_name()
.and_then(|n| n.to_str())
.map(|name| name.contains(".so."))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_shared_library_linux() {
assert!(is_shared_library(Path::new("/opt/addons/liboperate.so")));
assert!(is_shared_library(Path::new("addon.so.1")));
}
#[test]
fn test_is_shared_library_macos() {
assert!(is_shared_library(Path::new("/opt/addons/liboperate.dylib")));
}
#[test]
fn test_is_shared_library_windows() {
assert!(is_shared_library(Path::new("C:\\addons\\operate.dll")));
}
#[test]
fn test_is_shared_library_rejects_non_lib() {
assert!(!is_shared_library(Path::new("/opt/addons/operate.txt")));
assert!(!is_shared_library(Path::new("/opt/addons/operate")));
assert!(!is_shared_library(Path::new("/opt/addons/manifest.json")));
}
#[test]
fn test_loader_new_empty() {
let loader = HotAddonLoader::new();
assert!(loader.loaded_addons().is_empty());
assert!(loader.scan_dirs.is_empty());
}
#[test]
fn test_loader_add_scan_dir() {
let mut loader = HotAddonLoader::new();
loader.add_scan_dir("/opt/addons");
loader.add_scan_dir("/opt/plugins");
assert_eq!(loader.scan_dirs.len(), 2);
assert_eq!(loader.scan_dirs[0], Path::new("/opt/addons"));
assert_eq!(loader.scan_dirs[1], Path::new("/opt/plugins"));
}
#[test]
fn test_loader_get_manifest_unknown() {
let loader = HotAddonLoader::new();
assert!(loader.get_manifest("nonexistent").is_none());
}
#[test]
fn test_unload_unknown_addon() {
let loader = HotAddonLoader::new();
let result = loader.unload("nonexistent");
assert!(result.is_err());
}
#[test]
fn test_addon_init_result_serialization() {
let result = AddonInitResult {
name: "operate".to_string(),
version: "1.0.0".to_string(),
description: Some("CRUD 业务插件".to_string()),
dependencies: vec!["auth".to_string()],
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("operate"));
assert!(json.contains("1.0.0"));
assert!(json.contains("CRUD"));
let decoded: AddonInitResult = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.name, "operate");
assert_eq!(decoded.version, "1.0.0");
}
#[test]
fn test_hot_addon_manifest_serialization() {
let manifest = HotAddonManifest {
name: "crm".to_string(),
version: "2.1.0".to_string(),
description: None,
file_path: PathBuf::from("/opt/addons/libcrm.so"),
dependencies: vec![],
loaded_at: chrono::Utc::now(),
};
let json = serde_json::to_string(&manifest).unwrap();
assert!(json.contains("crm"));
assert!(json.contains("2.1.0"));
assert!(json.contains("libcrm.so"));
}
#[tokio::test]
async fn test_scan_empty_dir_returns_empty() {
let tmp = tempfile::tempdir().unwrap();
let mut loader = HotAddonLoader::new();
loader.add_scan_dir(tmp.path());
let results = loader.scan().await;
assert!(results.is_empty());
}
#[tokio::test]
async fn test_scan_nonexistent_dir_returns_error() {
let mut loader = HotAddonLoader::new();
loader.add_scan_dir("/this/path/does/not/exist/xyz123");
let results = loader.scan().await;
assert_eq!(results.len(), 1);
assert!(results[0].1.is_err());
}
#[tokio::test]
async fn test_scan_dir_with_non_lib_files_ignored() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("readme.txt"), "hello").unwrap();
std::fs::write(tmp.path().join("manifest.json"), "{}").unwrap();
let mut loader = HotAddonLoader::new();
loader.add_scan_dir(tmp.path());
let results = loader.scan().await;
assert!(results.is_empty());
}
#[test]
fn test_load_fake_library_fails_gracefully() {
let tmp = tempfile::tempdir().unwrap();
let fake_so = tmp.path().join("libfake.so");
std::fs::write(&fake_so, "not a real shared library").unwrap();
let loader = HotAddonLoader::new();
let result = loader.load_library(&fake_so, "fake");
assert!(result.is_err());
}
}