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 std::time::Duration;
12use tokio::sync::mpsc;
13use tokio::sync::Mutex as AsyncMutex;
14use tracing::{error, info, warn};
15
16use zerolaunch_plugin_api::config::{ComponentType, Configurable};
17use zerolaunch_plugin_api::plugin::PluginMetadata;
18use zerolaunch_plugin_api::PluginContext;
19use zerolaunch_plugin_protocol::manifest::Manifest;
20use zerolaunch_plugin_protocol::messages::{ComponentKind, KeywordOptimizerInfo};
21use zerolaunch_plugin_protocol::ProtocolError;
22
23use crate::adapter::remote_component::{RemoteComponent, RemoteComponentKind};
24use crate::host_dispatch::HostCallHandler;
25use crate::info::PluginInfo;
26use crate::installed::{metadata_from_manifest, InstalledCache};
27use crate::process::force_kill_process;
28use crate::process::{PluginProcess, ProcessState};
29
30const PROCESS_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
32
33pub type RestartCallback =
36 Arc<dyn Fn(PluginRegistration) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
37
38pub type CrashCallback = Arc<dyn Fn(PluginRegistration) + Send + Sync>;
42
43#[derive(Clone, Debug)]
49pub struct PluginRegistration {
50 pub plugin_id: String,
52 pub manifest: Manifest,
54 pub plugin_dir: PathBuf,
57 pub metadata: Arc<PluginMetadata>,
60
61 pub components: Vec<Arc<RemoteComponent>>,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum PluginRuntimeState {
77 #[serde(rename = "starting")]
79 Starting,
80 #[serde(rename = "running")]
82 Running,
83 #[serde(rename = "stopped")]
85 Stopped,
86 #[serde(rename = "crashed")]
88 Crashed,
89 #[serde(rename = "error")]
91 Error,
92 #[serde(rename = "unknown")]
94 #[default]
95 Unknown,
96}
97
98impl From<&ProcessState> for PluginRuntimeState {
99 fn from(state: &ProcessState) -> Self {
100 match state {
101 ProcessState::Starting => Self::Starting,
102 ProcessState::Running => Self::Running,
103 ProcessState::Stopped => Self::Stopped,
104 ProcessState::Crashed { .. } => Self::Crashed,
105 ProcessState::Error(_) => Self::Error,
106 }
107 }
108}
109
110struct PluginRestartContext {
112 manifest: Manifest,
113 plugin_dir: PathBuf,
114 host_call_handler: Arc<dyn HostCallHandler>,
115 crash_tx: mpsc::Sender<String>,
117 on_restart: RestartCallback,
121 on_crash: CrashCallback,
123 restart_count: AtomicU32,
126 locale: RwLock<String>,
128}
129
130impl std::fmt::Debug for PluginRestartContext {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 f.debug_struct("PluginRestartContext")
133 .field("manifest", &self.manifest)
134 .field("plugin_dir", &self.plugin_dir)
135 .field("host_call_handler", &self.host_call_handler)
136 .field("crash_tx", &self.crash_tx)
137 .field("on_restart", &"<RestartCallback>")
138 .field("restart_count", &self.restart_count)
139 .finish()
140 }
141}
142
143pub struct PluginHostManager {
145 pub processes: Arc<DashMap<String, Arc<PluginProcess>>>,
147 pub plugins: Arc<DashMap<String, PluginRegistration>>,
149 pub data_dir_root: PathBuf,
151 pub log_dir_root: PathBuf,
153 pub(crate) plugins_dir: PathBuf,
155 pub(crate) installed_cache: Arc<InstalledCache>,
157 pub(crate) installed_watcher: parking_lot::Mutex<Option<notify::RecommendedWatcher>>,
160 restart_contexts: Arc<DashMap<String, Arc<PluginRestartContext>>>,
162 load_lock: Arc<AsyncMutex<()>>,
164 builtin_component_ids: RwLock<HashSet<String>>,
170 self_arc: OnceLock<Arc<Self>>,
175}
176
177#[derive(Debug, thiserror::Error)]
179pub enum PluginLoadError {
180 #[error("manifest error: {0}")]
181 Manifest(String),
182 #[error("protocol error: {0}")]
183 Protocol(#[from] ProtocolError),
184 #[error("plugin already loaded: {0}")]
185 AlreadyLoaded(String),
186 #[error("plugin not found: {0}")]
187 NotFound(String),
188 #[error(
190 "component id collision for plugin {plugin_id}: '{component_id}' is already registered"
191 )]
192 ComponentIdCollision {
193 plugin_id: String,
195 component_id: String,
197 },
198}
199
200impl PluginHostManager {
201 pub fn new(plugins_dir: PathBuf, data_dir_root: PathBuf, log_dir_root: PathBuf) -> Arc<Self> {
202 let installed_cache = Arc::new(InstalledCache::load(plugins_dir.clone()));
204 let mgr = Arc::new(Self {
205 processes: Arc::new(DashMap::new()),
206 plugins: Arc::new(DashMap::new()),
207 data_dir_root,
208 log_dir_root,
209 plugins_dir,
210 installed_cache,
211 installed_watcher: parking_lot::Mutex::new(None),
212 restart_contexts: Arc::new(DashMap::new()),
213 load_lock: Arc::new(AsyncMutex::new(())),
214 builtin_component_ids: RwLock::new(HashSet::new()),
215 self_arc: OnceLock::new(),
216 });
217 if mgr.self_arc.set(Arc::clone(&mgr)).is_err() {
218 panic!("PluginHostManager self arc already set");
219 }
220 mgr
221 }
222
223 pub fn set_builtin_component_ids(&self, ids: HashSet<String>) {
228 *self.builtin_component_ids.write() = ids;
229 }
230
231 fn spawn_crash_loop(&self, crash_rx: mpsc::Receiver<String>) {
237 let mgr = Arc::clone(self.self_arc.get().expect("self arc set in load"));
238 tokio::spawn(async move {
239 crash_loop(mgr, crash_rx).await;
240 });
241 }
242
243 pub async fn load(
252 &self,
253 plugin_dir: &Path,
254 host_call_handler: Arc<dyn HostCallHandler>,
255 on_restart: RestartCallback,
256 on_crash: CrashCallback,
257 restart_count: u32,
258 locale: &str,
259 ) -> Result<PluginRegistration, PluginLoadError> {
260 let manifest_path = plugin_dir.join("manifest.toml");
261 let manifest_bytes = std::fs::read_to_string(&manifest_path)
262 .map_err(|e| PluginLoadError::Manifest(format!("cannot read manifest.toml: {}", e)))?;
263
264 let manifest: Manifest = toml::from_str(&manifest_bytes)
265 .map_err(|e| PluginLoadError::Manifest(format!("invalid manifest: {}", e)))?;
266
267 validate_manifest(&manifest, plugin_dir)?;
269
270 let plugin_id = manifest.plugin.id.clone();
271
272 let _guard = self.load_lock.lock().await;
274
275 if self.processes.contains_key(&plugin_id) {
277 return Err(PluginLoadError::AlreadyLoaded(plugin_id));
278 }
279
280 let data_dir = self.data_dir_root.join(&plugin_id);
281 let log_dir = self.log_dir_root.clone();
282
283 if let Err(e) = std::fs::create_dir_all(&data_dir) {
285 warn!(
286 "Failed to create plugin data dir {}: {}",
287 data_dir.display(),
288 e
289 );
290 }
291 if let Err(e) = std::fs::create_dir_all(&log_dir) {
292 warn!(
293 "Failed to create plugin log dir {}: {}",
294 log_dir.display(),
295 e
296 );
297 }
298
299 info!("Loading plugin {} from {}", plugin_id, plugin_dir.display());
300
301 let (crash_tx, crash_rx) = mpsc::channel::<String>(4);
303
304 let process = match PluginProcess::spawn(
306 &manifest,
307 plugin_dir,
308 &data_dir,
309 &log_dir,
310 host_call_handler.clone(),
311 crash_tx.clone(),
312 restart_count,
313 locale,
314 )
315 .await
316 {
317 Ok(p) => p,
318 Err(e) => {
319 return Err(e.into());
321 }
322 };
323
324 let client = process.client.clone();
326
327 self.restart_contexts.insert(
329 plugin_id.clone(),
330 Arc::new(PluginRestartContext {
331 manifest: manifest.clone(),
332 plugin_dir: plugin_dir.to_path_buf(),
333 host_call_handler,
334 crash_tx,
335 on_restart,
336 on_crash,
337 restart_count: AtomicU32::new(restart_count),
338 locale: RwLock::new(locale.to_string()),
339 }),
340 );
341
342 self.spawn_crash_loop(crash_rx);
344
345 let process = Arc::new(process);
349 self.processes.insert(plugin_id.clone(), process.clone());
350
351 let init_result = match process.discover_components().await {
353 Ok(result) => result,
354 Err(e) => {
355 self.shutdown_loaded_process(&plugin_id).await;
356 return Err(PluginLoadError::Protocol(e));
357 }
358 };
359
360 let adapters = build_components(&plugin_id, plugin_dir, &manifest, client, &init_result);
362
363 if let Err(e) = validate_component_contract(&manifest, &adapters.components) {
366 error!("拒绝加载插件 {}:{}", plugin_id, e);
367 self.shutdown_loaded_process(&plugin_id).await;
368 return Err(e);
369 }
370
371 let component_ids = adapters
377 .components
378 .iter()
379 .map(|c| c.component_id().to_string())
380 .collect::<Vec<_>>();
381 if let Some(collision) =
382 find_component_id_collision(&component_ids, |id| self.component_id_is_taken(id))
383 {
384 error!(
385 "拒绝加载插件 {}:组件 id '{}' 已被其他已注册组件占用,清理进程并放弃加载",
386 plugin_id, collision
387 );
388 self.shutdown_loaded_process(&plugin_id).await;
389 return Err(PluginLoadError::ComponentIdCollision {
390 plugin_id,
391 component_id: collision,
392 });
393 }
394
395 let registered = adapters.clone();
397 self.plugins.insert(plugin_id.clone(), adapters);
398 Ok(registered)
399 }
400
401 pub fn plugins_dir(&self) -> &Path {
403 &self.plugins_dir
404 }
405
406 fn component_id_is_taken(&self, id: &str) -> bool {
411 let taken_by_plugin = self
412 .plugins
413 .iter()
414 .any(|e| e.value().components.iter().any(|c| c.component_id() == id));
415 taken_by_plugin || self.builtin_component_ids.read().contains(id)
416 }
417
418 pub async fn unload(&self, plugin_id: &str) -> Result<(), PluginLoadError> {
421 info!("Unloading plugin {}", plugin_id);
422 self.teardown(plugin_id).await;
423 for suffix in ["", ".1"] {
425 let log_file = self
426 .log_dir_root
427 .join(format!("{}.log{}", plugin_id, suffix));
428 let _ = std::fs::remove_file(&log_file);
429 }
430 Ok(())
431 }
432
433 async fn teardown(&self, plugin_id: &str) {
440 let _guard = self.load_lock.lock().await;
441
442 let locale = self
444 .restart_contexts
445 .get(plugin_id)
446 .map(|ctx| ctx.locale.read().clone())
447 .unwrap_or_default();
448 let plugin = self.plugins.get(plugin_id).and_then(|reg| {
449 reg.components
450 .iter()
451 .find_map(|comp| Arc::clone(comp).as_plugin())
452 });
453
454 if let Some((_, ctx)) = self.restart_contexts.remove(plugin_id) {
456 ctx.host_call_handler.detach_event_sink();
457 }
458
459 if let Some(plugin) = plugin {
461 let mut ctx = PluginContext::new("shutdown");
462 ctx.plugin_id = Some(plugin_id.to_string());
463 ctx.locale = locale;
464 if let Err(e) = plugin.on_shutdown(&ctx).await {
465 warn!("Plugin {} shutdown hook failed: {}", plugin_id, e);
466 }
467 }
468
469 self.shutdown_loaded_process(plugin_id).await;
470 self.plugins.remove(plugin_id);
471 }
472
473 async fn shutdown_loaded_process(&self, plugin_id: &str) {
479 if let Some((_, proc)) = self.processes.remove(plugin_id) {
480 match Arc::try_unwrap(proc) {
481 Ok(process) => process.shutdown(PROCESS_SHUTDOWN_TIMEOUT).await,
482 Err(arc) => {
483 warn!(
484 "Plugin {} process Arc has {} strong references; forcing kill. \n This may indicate a leaked clone of the process handle.",
485 plugin_id,
486 Arc::strong_count(&arc)
487 );
488 arc.state.write().clone_from(&ProcessState::Stopped);
489 arc.client.close_transport();
490 if let Some(pid) = arc.pid {
491 force_kill_process(pid);
492 }
493 }
494 }
495 }
496 }
497
498 pub async fn shutdown_all(&self) {
507 let _guard = self.load_lock.lock().await;
508
509 for plugin_id in self
511 .restart_contexts
512 .iter()
513 .map(|e| e.key().clone())
514 .collect::<Vec<_>>()
515 {
516 if let Some((_, ctx)) = self.restart_contexts.remove(&plugin_id) {
517 ctx.host_call_handler.detach_event_sink();
518 }
519 }
520 self.plugins.clear();
521
522 let processes: Vec<Arc<PluginProcess>> = self
523 .processes
524 .iter()
525 .map(|e| (e.key().clone(), Arc::clone(e.value())))
526 .collect::<Vec<_>>()
527 .into_iter()
528 .filter_map(|(plugin_id, proc)| self.processes.remove(&plugin_id).map(|_| proc))
529 .collect();
530
531 futures::future::join_all(processes.into_iter().map(|proc| async move {
532 match Arc::try_unwrap(proc) {
533 Ok(process) => process.shutdown(PROCESS_SHUTDOWN_TIMEOUT).await,
534 Err(arc) => {
535 warn!(
536 "Plugin {} process Arc has {} strong references during exit; forcing kill.",
537 arc.plugin_id,
538 Arc::strong_count(&arc)
539 );
540 arc.state.write().clone_from(&ProcessState::Stopped);
541 arc.client.close_transport();
542 if let Some(pid) = arc.pid {
543 force_kill_process(pid);
544 }
545 }
546 }
547 }))
548 .await;
549 }
550
551 pub fn update_locale(&self, plugin_id: &str, locale: &str) {
557 if let Some(ctx) = self.restart_contexts.get_mut(plugin_id) {
558 *ctx.locale.write() = locale.to_string();
559 }
560 }
561
562 pub fn get_plugin_info(
567 &self,
568 plugin_id: &str,
569 is_component_enabled: impl Fn(&str) -> bool,
570 ) -> Option<PluginInfo> {
571 let entry = self.plugins.get(plugin_id)?;
572 let registration = entry.value();
573 let process_state = self
574 .processes
575 .get(plugin_id)
576 .map(|p| PluginRuntimeState::from(&*p.state.read()))
577 .unwrap_or_default();
578 let enabled = is_component_enabled(®istration.metadata.id);
580 Some(PluginInfo::from_metadata(
581 ®istration.metadata,
582 process_state,
583 enabled,
584 ))
585 }
586}
587
588fn validate_manifest(manifest: &Manifest, plugin_dir: &Path) -> Result<(), PluginLoadError> {
591 let id = &manifest.plugin.id;
592
593 static PLUGIN_ID_RE: OnceLock<regex::Regex> = OnceLock::new();
595 let re = PLUGIN_ID_RE.get_or_init(|| {
596 regex::Regex::new(zerolaunch_plugin_protocol::manifest::PLUGIN_ID_RE).unwrap()
597 });
598 if !re.is_match(id) {
599 return Err(PluginLoadError::Manifest(format!(
600 "invalid plugin id '{}': must match reverse domain",
601 id
602 )));
603 }
604
605 if semver::Version::parse(&manifest.plugin.version).is_err() {
607 return Err(PluginLoadError::Manifest(format!(
608 "invalid plugin version '{}'",
609 manifest.plugin.version
610 )));
611 }
612
613 if manifest.components.provides.is_empty() {
615 return Err(PluginLoadError::Manifest(
616 "components.provides must have at least one entry".into(),
617 ));
618 }
619
620 for p in &manifest.components.provides {
621 if !zerolaunch_plugin_protocol::manifest::REQUIRED_PROVIDES_VALUES.contains(&p.as_str()) {
622 return Err(PluginLoadError::Manifest(format!(
623 "unknown component type '{}'",
624 p
625 )));
626 }
627 }
628
629 let cmd_path = plugin_dir.join(&manifest.runtime.command);
631 let canonical_cmd = cmd_path
632 .canonicalize()
633 .map_err(|e| PluginLoadError::Manifest(format!("command not found: {}", e)))?;
634 let canonical_plugin_dir = plugin_dir
635 .canonicalize()
636 .map_err(|e| PluginLoadError::Manifest(format!("plugin dir canonicalize: {}", e)))?;
637 if !canonical_cmd.starts_with(&canonical_plugin_dir) {
638 return Err(PluginLoadError::Manifest(
639 "command path escapes plugin directory".into(),
640 ));
641 }
642
643 if let Some(icon) = &manifest.icon {
647 if let Ok(canonical_icon) = plugin_dir.join(&icon.path).canonicalize() {
648 if !canonical_icon.starts_with(&canonical_plugin_dir) {
649 return Err(PluginLoadError::Manifest(
650 "icon path escapes plugin directory".into(),
651 ));
652 }
653 }
654 }
655
656 Ok(())
657}
658
659fn find_by_id<T: Clone + Default>(map: &[(String, T)], component_id: &str) -> T {
663 map.iter()
664 .find(|(id, _)| id == component_id)
665 .map(|(_, v)| v.clone())
666 .unwrap_or_default()
667}
668
669fn find_settings_value(
671 values: &[(String, serde_json::Value)],
672 component_id: &str,
673) -> serde_json::Value {
674 values
675 .iter()
676 .find(|(id, _)| id == component_id)
677 .map(|(_, v)| v.clone())
678 .unwrap_or(serde_json::Value::Null)
679}
680
681fn component_type_of(kind: &ComponentKind) -> ComponentType {
685 match kind {
686 ComponentKind::Plugin => ComponentType::Plugin,
687 ComponentKind::DataSource => ComponentType::DataSource,
688 ComponentKind::ActionExecutor { .. } => ComponentType::ActionExecutor,
689 ComponentKind::SearchEngine => ComponentType::SearchEngine,
690 ComponentKind::ScoreBooster => ComponentType::ScoreBooster,
691 ComponentKind::KeywordOptimizer => ComponentType::KeywordOptimizer,
692 ComponentKind::KeywordInjector => ComponentType::KeywordInjector,
693 }
694}
695
696fn validate_component_contract(
703 manifest: &Manifest,
704 components: &[Arc<RemoteComponent>],
705) -> Result<(), PluginLoadError> {
706 let plugin_components: Vec<&Arc<RemoteComponent>> =
707 components.iter().filter(|c| c.is_plugin()).collect();
708 if plugin_components.len() != 1 {
709 return Err(PluginLoadError::Manifest(format!(
710 "components must declare exactly one plugin component, found {}",
711 plugin_components.len()
712 )));
713 }
714 let plugin_component_id = plugin_components[0].component_id();
715 if plugin_component_id != manifest.plugin.id {
716 return Err(PluginLoadError::Manifest(format!(
717 "plugin component id '{plugin_component_id}' must equal plugin id '{}'",
718 manifest.plugin.id
719 )));
720 }
721
722 let mut actual: HashSet<&str> = HashSet::new();
723 for c in components {
724 if c.is_plugin() {
725 actual.insert("plugin");
726 }
727 if c.is_data_source() {
728 actual.insert("data_source");
729 }
730 if c.is_action_executor() {
731 actual.insert("action_executor");
732 }
733 if c.is_search_engine() {
734 actual.insert("search_engine");
735 }
736 if c.is_score_booster() {
737 actual.insert("score_booster");
738 }
739 if c.is_keyword_optimizer() {
740 actual.insert("keyword_optimizer");
741 }
742 if c.is_keyword_injector() {
743 actual.insert("keyword_injector");
744 }
745 }
746
747 let declared: HashSet<&str> = manifest
748 .components
749 .provides
750 .iter()
751 .map(String::as_str)
752 .collect();
753 if declared != actual {
754 let mut declared: Vec<&str> = declared.into_iter().collect();
755 let mut actual: Vec<&str> = actual.into_iter().collect();
756 declared.sort_unstable();
757 actual.sort_unstable();
758 return Err(PluginLoadError::Manifest(format!(
759 "components.provides {:?} does not match actual components {:?}",
760 declared, actual
761 )));
762 }
763 Ok(())
764}
765
766fn find_component_id_collision(
773 component_ids: &[String],
774 component_id_taken: impl Fn(&str) -> bool,
775) -> Option<String> {
776 component_ids
777 .iter()
778 .find(|id| component_id_taken(id))
779 .cloned()
780}
781
782fn build_components(
787 plugin_id: &str,
788 plugin_dir: &Path,
789 manifest: &Manifest,
790 client: Arc<crate::client::JsonRpcClient>,
791 init_result: &crate::process::InitResult,
792) -> PluginRegistration {
793 let plugin_metadata = Arc::new(metadata_from_manifest(plugin_dir, manifest));
795
796 let components: Vec<Arc<RemoteComponent>> = init_result
797 .components
798 .iter()
799 .map(|comp| {
800 let schema = find_by_id(&init_result.settings_schemas, &comp.component_id);
801 let settings = find_settings_value(&init_result.settings_values, &comp.component_id);
802 let config_actions = find_by_id(&init_result.config_actions_map, &comp.component_id);
803 let default_enabled = find_by_id(&init_result.default_enabled_map, &comp.component_id);
804
805 let priority = comp.priority;
807
808 let kind = match &comp.kind {
809 ComponentKind::Plugin => {
810 let interaction_policy =
811 find_by_id(&init_result.interaction_policy_map, &comp.component_id);
812 RemoteComponentKind::Plugin {
813 metadata: Arc::clone(&plugin_metadata),
814 interaction_policy: parking_lot::RwLock::new(interaction_policy),
815 }
816 }
817 ComponentKind::DataSource => RemoteComponentKind::DataSource,
818 ComponentKind::ActionExecutor { target_types } => {
819 let result_actions =
820 find_by_id(&init_result.executor_actions_map, &comp.component_id);
821 RemoteComponentKind::ActionExecutor {
822 target_types: target_types.clone(),
823 result_actions,
824 }
825 }
826 ComponentKind::SearchEngine => RemoteComponentKind::SearchEngine,
827 ComponentKind::ScoreBooster => RemoteComponentKind::ScoreBooster,
828 ComponentKind::KeywordOptimizer => {
829 let info = init_result
831 .keyword_optimizer_info_map
832 .iter()
833 .find(|(id, _)| id == &comp.component_id)
834 .map(|(_, v)| v.clone())
835 .unwrap_or_else(|| KeywordOptimizerInfo::fallback(priority));
836 RemoteComponentKind::KeywordOptimizer {
837 info: parking_lot::RwLock::new(info),
838 }
839 }
840 ComponentKind::KeywordInjector => RemoteComponentKind::KeywordInjector,
841 };
842
843 Arc::new(RemoteComponent::new(
844 comp.component_id.clone(),
845 comp.component_name.clone(),
846 comp.component_description.clone(),
847 component_type_of(&comp.kind),
848 priority,
849 client.clone(),
850 schema,
851 settings,
852 config_actions,
853 default_enabled,
854 kind,
855 ))
856 })
857 .collect();
858
859 let registration = PluginRegistration {
860 plugin_id: plugin_id.to_string(),
861 manifest: manifest.clone(),
862 plugin_dir: plugin_dir.to_path_buf(),
864 metadata: plugin_metadata,
865 components,
866 };
867
868 #[cfg(debug_assertions)]
869 {
870 for comp in ®istration.components {
873 if let RemoteComponentKind::Plugin { metadata, .. } = &comp.kind {
874 debug_assert!(
875 Arc::ptr_eq(metadata, ®istration.metadata),
876 "组件元数据与 PluginRegistration.metadata 必须同源共享(Arc::clone)"
877 );
878 }
879 }
880 }
881
882 registration
883}
884
885async fn crash_loop(mgr: Arc<PluginHostManager>, mut crash_rx: mpsc::Receiver<String>) {
889 while let Some(plugin_id) = crash_rx.recv().await {
890 handle_crash(&mgr, &plugin_id).await;
891 }
892}
893
894async fn handle_crash(mgr: &PluginHostManager, plugin_id: &str) {
899 warn!("Watchdog triggered restart for plugin: {}", plugin_id);
900
901 let prev = mgr.plugins.remove(plugin_id);
905 mgr.processes.remove(plugin_id);
906
907 let Some(ctx) = mgr
910 .restart_contexts
911 .get(plugin_id)
912 .map(|r| Arc::clone(r.value()))
913 else {
914 warn!(
915 "Crash notification for plugin '{}' but no restart context found — \
916 plugin may have been unloaded concurrently",
917 plugin_id
918 );
919 return;
920 };
921 ctx.host_call_handler.detach_event_sink();
923
924 if let Some((_, prev)) = prev {
925 (ctx.on_crash)(prev);
926 }
927 let new_count = ctx.restart_count.fetch_add(1, Ordering::SeqCst) + 1;
931 let max_restart = ctx.manifest.runtime.max_restart;
932 if new_count > max_restart {
933 error!(
934 "Plugin {} exceeded max restarts ({}/{}) — not restarting",
935 plugin_id, new_count, max_restart
936 );
937 mgr.restart_contexts.remove(plugin_id);
938 return;
939 }
940
941 let locale = ctx.locale.read().clone();
947 match mgr
948 .load(
949 &ctx.plugin_dir,
950 ctx.host_call_handler.clone(),
951 ctx.on_restart.clone(),
952 ctx.on_crash.clone(),
953 new_count,
954 &locale,
955 )
956 .await
957 {
958 Ok(registered) => {
959 (ctx.on_restart)(registered).await;
961 info!("Plugin {} successfully restarted", plugin_id);
962 }
963 Err(e) => {
964 error!("Failed to restart plugin {}: {}", plugin_id, e);
965 mgr.restart_contexts.remove(plugin_id);
966 }
967 }
968}
969
970#[cfg(test)]
971mod tests {
972 use super::find_component_id_collision;
973
974 #[test]
976 fn collision_check_finds_first_taken_id() {
977 let ids = vec!["free".to_string(), "taken".to_string(), "other".to_string()];
978 let taken = |id: &str| id == "taken";
979 assert_eq!(
980 find_component_id_collision(&ids, taken),
981 Some("taken".to_string())
982 );
983 }
984
985 #[test]
987 fn collision_check_returns_none_when_all_free() {
988 let ids = vec!["a".to_string(), "b".to_string()];
989 assert_eq!(find_component_id_collision(&ids, |_| false), None);
990 }
991}