1use dashmap::DashMap;
4use parking_lot::RwLock;
5use std::collections::HashSet;
6use std::future::Future;
7use std::path::{Path, PathBuf};
8use std::pin::Pin;
9use std::sync::atomic::{AtomicU32, Ordering};
10use std::sync::{Arc, OnceLock};
11use tokio::sync::mpsc;
12use tokio::sync::Mutex as AsyncMutex;
13use tracing::{error, info, warn};
14
15use zerolaunch_plugin_api::config::Configurable;
16use zerolaunch_plugin_api::plugin::PluginMetadata;
17use zerolaunch_plugin_protocol::manifest::Manifest;
18use zerolaunch_plugin_protocol::messages::{ComponentKind, KeywordOptimizerInfo};
19use zerolaunch_plugin_protocol::ProtocolError;
20
21use crate::adapter::remote_component::{RemoteComponent, RemoteComponentKind};
22use crate::host_dispatch::HostCallHandler;
23use crate::info::PluginInfo;
24use crate::installed::{metadata_from_manifest, InstalledCache};
25use crate::process::force_kill_process;
26use crate::process::{PluginProcess, ProcessState};
27
28pub type RestartCallback =
31 Arc<dyn Fn(PluginRegistration) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
32
33pub type CrashCallback = Arc<dyn Fn(PluginRegistration) + Send + Sync>;
37
38#[derive(Clone, Debug)]
44pub struct PluginRegistration {
45 pub plugin_id: String,
47 pub manifest: Manifest,
49 pub plugin_dir: PathBuf,
52 pub metadata: Arc<PluginMetadata>,
55
56 pub components: Vec<Arc<RemoteComponent>>,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum PluginRuntimeState {
72 #[serde(rename = "starting")]
74 Starting,
75 #[serde(rename = "running")]
77 Running,
78 #[serde(rename = "stopped")]
80 Stopped,
81 #[serde(rename = "crashed")]
83 Crashed,
84 #[serde(rename = "error")]
86 Error,
87 #[serde(rename = "unknown")]
89 #[default]
90 Unknown,
91}
92
93impl From<&ProcessState> for PluginRuntimeState {
94 fn from(state: &ProcessState) -> Self {
95 match state {
96 ProcessState::Starting => Self::Starting,
97 ProcessState::Running => Self::Running,
98 ProcessState::Stopped => Self::Stopped,
99 ProcessState::Crashed { .. } => Self::Crashed,
100 ProcessState::Error(_) => Self::Error,
101 }
102 }
103}
104
105struct PluginRestartContext {
107 manifest: Manifest,
108 plugin_dir: PathBuf,
109 host_call_handler: Arc<dyn HostCallHandler>,
110 crash_tx: mpsc::Sender<String>,
112 on_restart: RestartCallback,
116 on_crash: CrashCallback,
118 restart_count: AtomicU32,
121 locale: RwLock<String>,
123}
124
125impl std::fmt::Debug for PluginRestartContext {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 f.debug_struct("PluginRestartContext")
128 .field("manifest", &self.manifest)
129 .field("plugin_dir", &self.plugin_dir)
130 .field("host_call_handler", &self.host_call_handler)
131 .field("crash_tx", &self.crash_tx)
132 .field("on_restart", &"<RestartCallback>")
133 .field("restart_count", &self.restart_count)
134 .finish()
135 }
136}
137
138pub struct PluginHostManager {
140 pub processes: Arc<DashMap<String, Arc<PluginProcess>>>,
142 pub plugins: Arc<DashMap<String, PluginRegistration>>,
144 pub data_dir_root: PathBuf,
146 pub log_dir_root: PathBuf,
148 pub(crate) plugins_dir: PathBuf,
150 pub(crate) installed_cache: Arc<InstalledCache>,
152 pub(crate) installed_watcher: parking_lot::Mutex<Option<notify::RecommendedWatcher>>,
155 restart_contexts: Arc<DashMap<String, Arc<PluginRestartContext>>>,
157 load_lock: Arc<AsyncMutex<()>>,
159 builtin_component_ids: RwLock<HashSet<String>>,
165 self_arc: OnceLock<Arc<Self>>,
170}
171
172#[derive(Debug, thiserror::Error)]
174pub enum PluginLoadError {
175 #[error("manifest error: {0}")]
176 Manifest(String),
177 #[error("protocol error: {0}")]
178 Protocol(#[from] ProtocolError),
179 #[error("plugin already loaded: {0}")]
180 AlreadyLoaded(String),
181 #[error("plugin not found: {0}")]
182 NotFound(String),
183 #[error(
185 "component id collision for plugin {plugin_id}: '{component_id}' is already registered"
186 )]
187 ComponentIdCollision {
188 plugin_id: String,
190 component_id: String,
192 },
193}
194
195impl PluginHostManager {
196 pub fn new(plugins_dir: PathBuf, data_dir_root: PathBuf, log_dir_root: PathBuf) -> Arc<Self> {
197 let installed_cache = Arc::new(InstalledCache::load(plugins_dir.clone()));
199 let mgr = Arc::new(Self {
200 processes: Arc::new(DashMap::new()),
201 plugins: Arc::new(DashMap::new()),
202 data_dir_root,
203 log_dir_root,
204 plugins_dir,
205 installed_cache,
206 installed_watcher: parking_lot::Mutex::new(None),
207 restart_contexts: Arc::new(DashMap::new()),
208 load_lock: Arc::new(AsyncMutex::new(())),
209 builtin_component_ids: RwLock::new(HashSet::new()),
210 self_arc: OnceLock::new(),
211 });
212 if mgr.self_arc.set(Arc::clone(&mgr)).is_err() {
213 panic!("PluginHostManager self arc already set");
214 }
215 mgr
216 }
217
218 pub fn set_builtin_component_ids(&self, ids: HashSet<String>) {
223 *self.builtin_component_ids.write() = ids;
224 }
225
226 fn spawn_crash_loop(&self, crash_rx: mpsc::Receiver<String>) {
232 let mgr = Arc::clone(self.self_arc.get().expect("self arc set in load"));
233 tokio::spawn(async move {
234 crash_loop(mgr, crash_rx).await;
235 });
236 }
237
238 pub async fn load(
247 &self,
248 plugin_dir: &Path,
249 host_call_handler: Arc<dyn HostCallHandler>,
250 on_restart: RestartCallback,
251 on_crash: CrashCallback,
252 restart_count: u32,
253 locale: &str,
254 ) -> Result<PluginRegistration, PluginLoadError> {
255 let manifest_path = plugin_dir.join("manifest.toml");
256 let manifest_bytes = std::fs::read_to_string(&manifest_path)
257 .map_err(|e| PluginLoadError::Manifest(format!("cannot read manifest.toml: {}", e)))?;
258
259 let manifest: Manifest = toml::from_str(&manifest_bytes)
260 .map_err(|e| PluginLoadError::Manifest(format!("invalid manifest: {}", e)))?;
261
262 validate_manifest(&manifest, plugin_dir)?;
264
265 let plugin_id = manifest.plugin.id.clone();
266
267 let _guard = self.load_lock.lock().await;
269
270 if self.processes.contains_key(&plugin_id) {
272 return Err(PluginLoadError::AlreadyLoaded(plugin_id));
273 }
274
275 let data_dir = self.data_dir_root.join(&plugin_id);
276 let log_dir = self.log_dir_root.clone();
277
278 if let Err(e) = std::fs::create_dir_all(&data_dir) {
280 warn!(
281 "Failed to create plugin data dir {}: {}",
282 data_dir.display(),
283 e
284 );
285 }
286 if let Err(e) = std::fs::create_dir_all(&log_dir) {
287 warn!(
288 "Failed to create plugin log dir {}: {}",
289 log_dir.display(),
290 e
291 );
292 }
293
294 info!("Loading plugin {} from {}", plugin_id, plugin_dir.display());
295
296 let (crash_tx, crash_rx) = mpsc::channel::<String>(4);
298
299 let process = match PluginProcess::spawn(
301 &manifest,
302 plugin_dir,
303 &data_dir,
304 &log_dir,
305 host_call_handler.clone(),
306 crash_tx.clone(),
307 restart_count,
308 locale,
309 )
310 .await
311 {
312 Ok(p) => p,
313 Err(e) => {
314 return Err(e.into());
316 }
317 };
318
319 let client = process.client.clone();
321
322 self.restart_contexts.insert(
324 plugin_id.clone(),
325 Arc::new(PluginRestartContext {
326 manifest: manifest.clone(),
327 plugin_dir: plugin_dir.to_path_buf(),
328 host_call_handler,
329 crash_tx,
330 on_restart,
331 on_crash,
332 restart_count: AtomicU32::new(restart_count),
333 locale: RwLock::new(locale.to_string()),
334 }),
335 );
336
337 self.spawn_crash_loop(crash_rx);
339
340 let process = Arc::new(process);
344 self.processes.insert(plugin_id.clone(), process.clone());
345
346 let init_result = match process.discover_components().await {
348 Ok(result) => result,
349 Err(e) => {
350 self.shutdown_loaded_process(&plugin_id).await;
351 return Err(PluginLoadError::Protocol(e));
352 }
353 };
354
355 let adapters = build_components(&plugin_id, plugin_dir, &manifest, client, &init_result);
357
358 let component_ids = adapters
364 .components
365 .iter()
366 .map(|c| c.component_id().to_string())
367 .collect::<Vec<_>>();
368 if let Some(collision) =
369 find_component_id_collision(&component_ids, |id| self.component_id_is_taken(id))
370 {
371 error!(
372 "拒绝加载插件 {}:组件 id '{}' 已被其他已注册组件占用,清理进程并放弃加载",
373 plugin_id, collision
374 );
375 self.shutdown_loaded_process(&plugin_id).await;
376 return Err(PluginLoadError::ComponentIdCollision {
377 plugin_id,
378 component_id: collision,
379 });
380 }
381
382 let registered = adapters.clone();
384 self.plugins.insert(plugin_id.clone(), adapters);
385 Ok(registered)
386 }
387
388 pub fn plugins_dir(&self) -> &Path {
390 &self.plugins_dir
391 }
392
393 fn component_id_is_taken(&self, id: &str) -> bool {
398 let taken_by_plugin = self
399 .plugins
400 .iter()
401 .any(|e| e.value().components.iter().any(|c| c.component_id() == id));
402 taken_by_plugin || self.builtin_component_ids.read().contains(id)
403 }
404
405 pub async fn unload(&self, plugin_id: &str) -> Result<(), PluginLoadError> {
408 info!("Unloading plugin {}", plugin_id);
409 self.teardown(plugin_id).await;
410 for suffix in ["", ".1"] {
412 let log_file = self
413 .log_dir_root
414 .join(format!("{}.log{}", plugin_id, suffix));
415 let _ = std::fs::remove_file(&log_file);
416 }
417 Ok(())
418 }
419
420 async fn teardown(&self, plugin_id: &str) {
425 let _guard = self.load_lock.lock().await;
426 if let Some((_, ctx)) = self.restart_contexts.remove(plugin_id) {
428 ctx.host_call_handler.detach_event_sink();
429 }
430 self.shutdown_loaded_process(plugin_id).await;
431 self.plugins.remove(plugin_id);
432 }
433
434 async fn shutdown_loaded_process(&self, plugin_id: &str) {
438 if let Some((_, proc)) = self.processes.remove(plugin_id) {
439 match Arc::try_unwrap(proc) {
440 Ok(process) => process.shutdown(std::time::Duration::from_secs(5)).await,
441 Err(arc) => {
442 warn!(
443 "Plugin {} process Arc has {} strong references; forcing kill. \n This may indicate a leaked clone of the process handle.",
444 plugin_id,
445 Arc::strong_count(&arc)
446 );
447 arc.state.write().clone_from(&ProcessState::Stopped);
448 arc.client.close_transport();
449 if let Some(pid) = arc.pid {
450 force_kill_process(pid);
451 }
452 }
453 }
454 }
455 }
456
457 pub fn update_locale(&self, plugin_id: &str, locale: &str) {
463 if let Some(ctx) = self.restart_contexts.get_mut(plugin_id) {
464 *ctx.locale.write() = locale.to_string();
465 }
466 }
467
468 pub fn get_plugin_info(
473 &self,
474 plugin_id: &str,
475 is_component_enabled: impl Fn(&str) -> bool,
476 ) -> Option<PluginInfo> {
477 let entry = self.plugins.get(plugin_id)?;
478 let registration = entry.value();
479 let process_state = self
480 .processes
481 .get(plugin_id)
482 .map(|p| PluginRuntimeState::from(&*p.state.read()))
483 .unwrap_or_default();
484 let component_ids: Vec<String> = registration
485 .components
486 .iter()
487 .map(|c| c.component_id().to_string())
488 .collect();
489 let enabled = component_ids.iter().all(|id| is_component_enabled(id));
490 Some(PluginInfo::from_metadata(
491 ®istration.metadata,
492 process_state,
493 component_ids,
494 enabled,
495 ))
496 }
497}
498
499fn validate_manifest(manifest: &Manifest, plugin_dir: &Path) -> Result<(), PluginLoadError> {
502 let id = &manifest.plugin.id;
503
504 static PLUGIN_ID_RE: OnceLock<regex::Regex> = OnceLock::new();
506 let re = PLUGIN_ID_RE.get_or_init(|| {
507 regex::Regex::new(zerolaunch_plugin_protocol::manifest::PLUGIN_ID_RE).unwrap()
508 });
509 if !re.is_match(id) {
510 return Err(PluginLoadError::Manifest(format!(
511 "invalid plugin id '{}': must match reverse domain",
512 id
513 )));
514 }
515
516 if semver::Version::parse(&manifest.plugin.version).is_err() {
518 return Err(PluginLoadError::Manifest(format!(
519 "invalid plugin version '{}'",
520 manifest.plugin.version
521 )));
522 }
523
524 if manifest.components.provides.is_empty() {
526 return Err(PluginLoadError::Manifest(
527 "components.provides must have at least one entry".into(),
528 ));
529 }
530
531 for p in &manifest.components.provides {
532 if !zerolaunch_plugin_protocol::manifest::REQUIRED_PROVIDES_VALUES.contains(&p.as_str()) {
533 return Err(PluginLoadError::Manifest(format!(
534 "unknown component type '{}'",
535 p
536 )));
537 }
538 }
539
540 let cmd_path = plugin_dir.join(&manifest.runtime.command);
542 let canonical_cmd = cmd_path
543 .canonicalize()
544 .map_err(|e| PluginLoadError::Manifest(format!("command not found: {}", e)))?;
545 let canonical_plugin_dir = plugin_dir
546 .canonicalize()
547 .map_err(|e| PluginLoadError::Manifest(format!("plugin dir canonicalize: {}", e)))?;
548 if !canonical_cmd.starts_with(&canonical_plugin_dir) {
549 return Err(PluginLoadError::Manifest(
550 "command path escapes plugin directory".into(),
551 ));
552 }
553
554 if let Some(icon) = &manifest.icon {
558 if let Ok(canonical_icon) = plugin_dir.join(&icon.path).canonicalize() {
559 if !canonical_icon.starts_with(&canonical_plugin_dir) {
560 return Err(PluginLoadError::Manifest(
561 "icon path escapes plugin directory".into(),
562 ));
563 }
564 }
565 }
566
567 Ok(())
568}
569
570fn find_by_id<T: Clone + Default>(map: &[(String, T)], component_id: &str) -> T {
574 map.iter()
575 .find(|(id, _)| id == component_id)
576 .map(|(_, v)| v.clone())
577 .unwrap_or_default()
578}
579
580fn find_settings_value(
582 values: &[(String, serde_json::Value)],
583 component_id: &str,
584) -> serde_json::Value {
585 values
586 .iter()
587 .find(|(id, _)| id == component_id)
588 .map(|(_, v)| v.clone())
589 .unwrap_or(serde_json::Value::Null)
590}
591
592fn find_component_id_collision(
599 component_ids: &[String],
600 component_id_taken: impl Fn(&str) -> bool,
601) -> Option<String> {
602 component_ids
603 .iter()
604 .find(|id| component_id_taken(id))
605 .cloned()
606}
607
608fn build_components(
613 plugin_id: &str,
614 plugin_dir: &Path,
615 manifest: &Manifest,
616 client: Arc<crate::client::JsonRpcClient>,
617 init_result: &crate::process::InitResult,
618) -> PluginRegistration {
619 let plugin_metadata = Arc::new(metadata_from_manifest(plugin_dir, manifest));
621
622 let components: Vec<Arc<RemoteComponent>> = init_result
623 .components
624 .iter()
625 .map(|comp| {
626 let schema = find_by_id(&init_result.settings_schemas, &comp.component_id);
627 let settings = find_settings_value(&init_result.settings_values, &comp.component_id);
628 let config_actions = find_by_id(&init_result.config_actions_map, &comp.component_id);
629 let default_enabled = find_by_id(&init_result.default_enabled_map, &comp.component_id);
630
631 let priority = comp.priority;
633
634 let kind = match &comp.kind {
635 ComponentKind::Plugin => {
636 let interaction_policy =
637 find_by_id(&init_result.interaction_policy_map, &comp.component_id);
638 RemoteComponentKind::Plugin {
639 metadata: Arc::clone(&plugin_metadata),
640 interaction_policy: parking_lot::RwLock::new(interaction_policy),
641 }
642 }
643 ComponentKind::DataSource => RemoteComponentKind::DataSource,
644 ComponentKind::ActionExecutor { target_types } => {
645 let result_actions =
646 find_by_id(&init_result.executor_actions_map, &comp.component_id);
647 RemoteComponentKind::ActionExecutor {
648 target_types: target_types.clone(),
649 result_actions,
650 }
651 }
652 ComponentKind::SearchEngine => RemoteComponentKind::SearchEngine,
653 ComponentKind::ScoreBooster => RemoteComponentKind::ScoreBooster,
654 ComponentKind::KeywordOptimizer => {
655 let info = init_result
657 .keyword_optimizer_info_map
658 .iter()
659 .find(|(id, _)| id == &comp.component_id)
660 .map(|(_, v)| v.clone())
661 .unwrap_or_else(|| KeywordOptimizerInfo::fallback(priority));
662 RemoteComponentKind::KeywordOptimizer {
663 info: parking_lot::RwLock::new(info),
664 }
665 }
666 ComponentKind::KeywordInjector => RemoteComponentKind::KeywordInjector,
667 };
668
669 Arc::new(RemoteComponent::new(
670 comp.component_id.clone(),
671 comp.component_name.clone(),
672 comp.component_description.clone(),
673 comp.component_type,
674 priority,
675 client.clone(),
676 schema,
677 settings,
678 config_actions,
679 default_enabled,
680 kind,
681 ))
682 })
683 .collect();
684
685 let registration = PluginRegistration {
686 plugin_id: plugin_id.to_string(),
687 manifest: manifest.clone(),
688 plugin_dir: plugin_dir.to_path_buf(),
690 metadata: plugin_metadata,
691 components,
692 };
693
694 #[cfg(debug_assertions)]
695 {
696 for comp in ®istration.components {
699 if let RemoteComponentKind::Plugin { metadata, .. } = &comp.kind {
700 debug_assert!(
701 Arc::ptr_eq(metadata, ®istration.metadata),
702 "组件元数据与 PluginRegistration.metadata 必须同源共享(Arc::clone)"
703 );
704 }
705 }
706 }
707
708 registration
709}
710
711async fn crash_loop(mgr: Arc<PluginHostManager>, mut crash_rx: mpsc::Receiver<String>) {
715 while let Some(plugin_id) = crash_rx.recv().await {
716 handle_crash(&mgr, &plugin_id).await;
717 }
718}
719
720async fn handle_crash(mgr: &PluginHostManager, plugin_id: &str) {
725 warn!("Watchdog triggered restart for plugin: {}", plugin_id);
726
727 let prev = mgr.plugins.remove(plugin_id);
731 mgr.processes.remove(plugin_id);
732
733 let Some(ctx) = mgr
736 .restart_contexts
737 .get(plugin_id)
738 .map(|r| Arc::clone(r.value()))
739 else {
740 warn!(
741 "Crash notification for plugin '{}' but no restart context found — \
742 plugin may have been unloaded concurrently",
743 plugin_id
744 );
745 return;
746 };
747 ctx.host_call_handler.detach_event_sink();
749
750 if let Some((_, prev)) = prev {
751 (ctx.on_crash)(prev);
752 }
753 let new_count = ctx.restart_count.fetch_add(1, Ordering::SeqCst) + 1;
757 let max_restart = ctx.manifest.runtime.max_restart;
758 if new_count > max_restart {
759 error!(
760 "Plugin {} exceeded max restarts ({}/{}) — not restarting",
761 plugin_id, new_count, max_restart
762 );
763 mgr.restart_contexts.remove(plugin_id);
764 return;
765 }
766
767 let locale = ctx.locale.read().clone();
773 match mgr
774 .load(
775 &ctx.plugin_dir,
776 ctx.host_call_handler.clone(),
777 ctx.on_restart.clone(),
778 ctx.on_crash.clone(),
779 new_count,
780 &locale,
781 )
782 .await
783 {
784 Ok(registered) => {
785 (ctx.on_restart)(registered).await;
787 info!("Plugin {} successfully restarted", plugin_id);
788 }
789 Err(e) => {
790 error!("Failed to restart plugin {}: {}", plugin_id, e);
791 mgr.restart_contexts.remove(plugin_id);
792 }
793 }
794}
795
796#[cfg(test)]
797mod tests {
798 use super::find_component_id_collision;
799
800 #[test]
802 fn collision_check_finds_first_taken_id() {
803 let ids = vec!["free".to_string(), "taken".to_string(), "other".to_string()];
804 let taken = |id: &str| id == "taken";
805 assert_eq!(
806 find_component_id_collision(&ids, taken),
807 Some("taken".to_string())
808 );
809 }
810
811 #[test]
813 fn collision_check_returns_none_when_all_free() {
814 let ids = vec!["a".to_string(), "b".to_string()];
815 assert_eq!(find_component_id_collision(&ids, |_| false), None);
816 }
817}