feat(plugin-api): add support for plugin dependencies

This commit is contained in:
Alexander Medvedev 2026-04-15 16:47:08 +02:00
parent 4bdc3d187b
commit 9012a92287
8 changed files with 296 additions and 128 deletions

View File

@ -112,7 +112,7 @@ proc-macro-error2 = "2"
aes = "0.9" aes = "0.9"
async-compression = "0.4.41" async-compression = "0.4.41"
base64 = "0.22.1" base64 = "0.22.1"
bitflags = "2.11.0" bitflags = "2.11.1"
cesu8 = "1.1" cesu8 = "1.1"
cfb8 = "0.9" cfb8 = "0.9"
colored = "3.1" colored = "3.1"
@ -131,7 +131,7 @@ hmac = "=0.13.0"
indexmap = "2.14" indexmap = "2.14"
itertools = "0.14.0" itertools = "0.14.0"
libloading = "0.9" libloading = "0.9"
lru = "0.16.3" lru = "0.17.0"
lz4-java-wrc = "0.2.0" lz4-java-wrc = "0.2.0"
md5 = "0.8" md5 = "0.8"
num-bigint = "0.4" num-bigint = "0.4"

View File

@ -74,9 +74,8 @@ and customizable experience. It prioritizes performance and player enjoyment whi
- [x] Mobs (W.I.P) - [x] Mobs (W.I.P)
- [x] Animals (W.I.P) - [x] Animals (W.I.P)
- [Entity AI](https://github.com/Pumpkin-MC/Pumpkin/issues/1406) - [Entity AI](https://github.com/Pumpkin-MC/Pumpkin/issues/1406)
- [ ] Boss - [x] Boss
- [ ] Villagers - [ ] Villagers
- [ ] Mobs Inventory
- [X] Entity Saving - [X] Entity Saving
- Server - Server
- [Plugins](https://github.com/Pumpkin-MC/Pumpkin/issues/1407) - [Plugins](https://github.com/Pumpkin-MC/Pumpkin/issues/1407)

View File

@ -87,6 +87,7 @@ pub fn plugin_impl(_attr: TokenStream, item: TokenStream) -> TokenStream {
version: env!("CARGO_PKG_VERSION").to_string(), version: env!("CARGO_PKG_VERSION").to_string(),
authors: env!("CARGO_PKG_AUTHORS").split(',').map(String::from).collect(), authors: env!("CARGO_PKG_AUTHORS").split(',').map(String::from).collect(),
description: env!("CARGO_PKG_DESCRIPTION").to_string(), description: env!("CARGO_PKG_DESCRIPTION").to_string(),
dependencies: Vec::new(),
} }
}); });

View File

@ -19,6 +19,7 @@
//! version: "0.1.0".into(), //! version: "0.1.0".into(),
//! authors: vec!["you".into()], //! authors: vec!["you".into()],
//! description: "An example plugin.".into(), //! description: "An example plugin.".into(),
//! dependencies: vec![],
//! } //! }
//! } //! }
//! } //! }
@ -78,6 +79,8 @@ pub struct PluginMetadata {
pub authors: Vec<String>, pub authors: Vec<String>,
/// A short description of what the plugin does. /// A short description of what the plugin does.
pub description: String, pub description: String,
/// The list of plugin dependencies.
pub dependencies: Vec<String>,
} }
impl wit::exports::pumpkin::plugin::metadata::Guest for Component { impl wit::exports::pumpkin::plugin::metadata::Guest for Component {
@ -89,6 +92,7 @@ impl wit::exports::pumpkin::plugin::metadata::Guest for Component {
version: metadata.version, version: metadata.version,
authors: metadata.authors, authors: metadata.authors,
description: metadata.description, description: metadata.description,
dependencies: metadata.dependencies,
} }
} }
} }

View File

@ -9,6 +9,8 @@ interface metadata {
authors: list<string>, authors: list<string>,
/// Short description of the plugin. /// Short description of the plugin.
description: string, description: string,
/// Plugin dependencies.
dependencies: list<string>,
} }
get-metadata: func() -> plugin-metadata; get-metadata: func() -> plugin-metadata;

View File

@ -22,6 +22,8 @@ pub struct PluginMetadata {
pub authors: Vec<String>, pub authors: Vec<String>,
/// A description of the plugin. /// A description of the plugin.
pub description: String, pub description: String,
/// The dependencies of the plugin.
pub dependencies: Vec<String>,
} }
/// This type represents a future for the plugin. /// This type represents a future for the plugin.

View File

@ -64,6 +64,7 @@ pub async fn init_plugin(
version: metadata.version, version: metadata.version,
authors: metadata.authors, authors: metadata.authors,
description: metadata.description, description: metadata.description,
dependencies: metadata.dependencies,
}; };
Ok(( Ok((

View File

@ -201,6 +201,9 @@ pub enum ManagerError {
#[error("Plugin manager not initialized properly")] #[error("Plugin manager not initialized properly")]
ManagerNotInitialized, ManagerNotInitialized,
#[error("Dependency error: {0}")]
DependencyError(String),
} }
impl Default for PluginManager { impl Default for PluginManager {
@ -291,57 +294,66 @@ impl PluginManager {
self.loaders.read().await.clone() self.loaders.read().await.clone()
} }
/// Load all plugins from the plugin directory /// Helper for topological sort of plugins based on dependencies
pub async fn load_plugins(&self) -> Result<(), ManagerError> { fn topological_sort(plugins: &[(String, Vec<String>)]) -> Result<Vec<String>, String> {
const PLUGIN_DIR: &str = "./plugins"; fn visit(
let path = Path::new(PLUGIN_DIR); name: &str,
deps_map: &HashMap<String, &Vec<String>>,
if !path.exists() { plugin_names: &HashSet<String>,
std::fs::create_dir(path)?; visited: &mut HashSet<String>,
return Ok(()); current_path: &mut HashSet<String>,
sorted: &mut Vec<String>,
) -> Result<(), String> {
if current_path.contains(name) {
return Err(format!(
"Circular dependency detected involving plugin: {name}"
));
} }
if !visited.contains(name) {
let mut load_tasks = Vec::new(); current_path.insert(name.to_string());
if let Some(deps) = deps_map.get(name) {
for entry in std::fs::read_dir(path)? { for dep in *deps {
let entry = entry?; if !plugin_names.contains(dep) {
let path = entry.path(); return Err(format!("Plugin {name} depends on missing plugin: {dep}"));
if path.is_dir() {
continue;
} }
visit(dep, deps_map, plugin_names, visited, current_path, sorted)?;
if path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("deactivated"))
{
continue;
}
// Start loading plugin concurrently
match self.start_loading_plugin(&path).await {
Ok(task) => load_tasks.push(task),
Err(err) => error!("{}", err),
} }
} }
current_path.remove(name);
// Wait for all plugins to complete loading (but don't block on individual plugin initialization) visited.insert(name.to_string());
join_all(load_tasks).await; sorted.push(name.to_string());
}
Ok(()) Ok(())
} }
let mut sorted = Vec::new();
let mut visited = HashSet::new();
let mut current_path = HashSet::new();
let plugin_names: HashSet<String> = plugins.iter().map(|(n, _)| n.clone()).collect();
let deps_map: HashMap<String, &Vec<String>> =
plugins.iter().map(|(n, d)| (n.clone(), d)).collect();
/// Start loading a plugin asynchronously for (name, _) in plugins {
#[expect(clippy::too_many_lines)] visit(
async fn start_loading_plugin( name,
&deps_map,
&plugin_names,
&mut visited,
&mut current_path,
&mut sorted,
)?;
}
Ok(sorted)
}
/// Spawn initialization for a single plugin
async fn spawn_plugin_initialization(
&self, &self,
path: &Path, mut instance: Box<dyn Plugin>,
metadata: PluginMetadata,
loader_data: Box<dyn Any + Send + Sync>,
loader: Arc<dyn PluginLoader>,
) -> Result<tokio::task::JoinHandle<()>, ManagerError> { ) -> Result<tokio::task::JoinHandle<()>, ManagerError> {
for loader in self.loaders.read().await.iter() {
if loader.can_load(path) {
let (mut instance, metadata, loader_data) = loader.load(path).await?;
// Mark plugin as loading // Mark plugin as loading
self.plugin_states self.plugin_states
.write() .write()
@ -386,9 +398,6 @@ impl PluginManager {
plugins.len() - 1 plugins.len() - 1
}; };
// Remove from unloaded files if it was there
self.unloaded_files.write().await.remove(path);
// Spawn async task for plugin initialization // Spawn async task for plugin initialization
let self_ref_clone = Arc::clone(&self_ref); let self_ref_clone = Arc::clone(&self_ref);
let state_notify = Arc::clone(&self.state_notify); let state_notify = Arc::clone(&self.state_notify);
@ -444,10 +453,11 @@ impl PluginManager {
plugins.remove(plugin_index); plugins.remove(plugin_index);
} }
}; };
self_ref_clone.plugin_states.write().await.insert( self_ref_clone
plugin_name.clone(), .plugin_states
PluginState::Failed(error_msg.clone()), .write()
); .await
.insert(plugin_name.clone(), PluginState::Failed(error_msg.clone()));
state_notify.notify_waiters(); state_notify.notify_waiters();
error!("Failed to initialize plugin {plugin_name}: {error_msg}",); error!("Failed to initialize plugin {plugin_name}: {error_msg}",);
@ -455,7 +465,118 @@ impl PluginManager {
} }
}); });
return Ok(task); Ok(task)
}
/// Load all plugins from the plugin directory
pub async fn load_plugins(&self) -> Result<(), ManagerError> {
const PLUGIN_DIR: &str = "./plugins";
let path = Path::new(PLUGIN_DIR);
if !path.exists() {
std::fs::create_dir(path)?;
return Ok(());
}
let mut prepared_plugins = Vec::new();
for entry in std::fs::read_dir(path)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
continue;
}
if path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("deactivated"))
{
continue;
}
// Find a loader that can handle this file
let loaders = self.loaders.read().await;
let mut loader_found = false;
for loader in loaders.iter() {
if loader.can_load(&path) {
match loader.load(&path).await {
Ok((instance, metadata, loader_data)) => {
prepared_plugins.push((
instance,
metadata,
loader_data,
loader.clone(),
));
loader_found = true;
}
Err(err) => error!("Failed to load plugin from {:?}: {}", path, err),
}
break;
}
}
if !loader_found {
self.unloaded_files.write().await.insert(path.clone());
}
}
// Resolve dependencies
let metadata_list: Vec<(String, Vec<String>)> = prepared_plugins
.iter()
.map(|(_, m, _, _)| (m.name.clone(), m.dependencies.clone()))
.collect();
let sorted_names =
Self::topological_sort(&metadata_list).map_err(ManagerError::DependencyError)?;
// Map names back to prepared plugins
#[expect(clippy::type_complexity)]
let mut plugins_map: HashMap<
String,
(
Box<dyn Plugin>,
PluginMetadata,
Box<dyn Any + Send + Sync>,
Arc<dyn PluginLoader>,
),
> = prepared_plugins
.into_iter()
.map(|(i, m, d, l)| (m.name.clone(), (i, m, d, l)))
.collect();
for name in sorted_names {
if let Some((instance, metadata, loader_data, loader)) = plugins_map.remove(&name) {
match self
.spawn_plugin_initialization(instance, metadata, loader_data, loader)
.await
{
Ok(task) => {
// We must await each initialization to ensure dependencies are ready
if let Err(err) = task.await {
error!("Plugin initialization task panicked: {}", err);
}
}
Err(err) => error!("{}", err),
}
}
}
Ok(())
}
/// Start loading a plugin asynchronously
async fn start_loading_plugin(
&self,
path: &Path,
) -> Result<tokio::task::JoinHandle<()>, ManagerError> {
for loader in self.loaders.read().await.iter() {
if loader.can_load(path) {
let (instance, metadata, loader_data) = loader.load(path).await?;
return self
.spawn_plugin_initialization(instance, metadata, loader_data, loader.clone())
.await;
} }
} }
@ -657,3 +778,41 @@ impl PluginManager {
event event
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn topological_sort() {
let plugins = vec![
("A".to_string(), vec!["B".to_string()]),
("B".to_string(), vec!["C".to_string()]),
("C".to_string(), vec![]),
];
let sorted = PluginManager::topological_sort(&plugins).unwrap();
assert_eq!(sorted, vec!["C", "B", "A"]);
let plugins_complex = vec![
("A".to_string(), vec!["B".to_string(), "C".to_string()]),
("B".to_string(), vec!["D".to_string()]),
("C".to_string(), vec!["D".to_string()]),
("D".to_string(), vec![]),
];
let sorted = PluginManager::topological_sort(&plugins_complex).unwrap();
// Multiple valid sorts possible, but D must be before B and C, and B, C must be before A.
assert_eq!(sorted[0], "D");
assert!(sorted[1] == "B" || sorted[1] == "C");
assert!(sorted[2] == "B" || sorted[2] == "C");
assert_eq!(sorted[3], "A");
let plugins_circular = vec![
("A".to_string(), vec!["B".to_string()]),
("B".to_string(), vec!["A".to_string()]),
];
assert!(PluginManager::topological_sort(&plugins_circular).is_err());
let plugins_missing = vec![("A".to_string(), vec!["B".to_string()])];
assert!(PluginManager::topological_sort(&plugins_missing).is_err());
}
}