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::{KeywordInputSource, PluginKind, PluginMetadata, PluginMode};
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::process::force_kill_process;
24use crate::process::{PluginProcess, ProcessState};
25
26pub type RestartCallback =
29 Arc<dyn Fn(PluginRegistration) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
30
31pub type CrashCallback = Arc<dyn Fn(PluginRegistration) + Send + Sync>;
35
36#[derive(Clone, Debug)]
42pub struct PluginRegistration {
43 pub plugin_id: String,
45 pub manifest: Manifest,
47 pub plugin_dir: PathBuf,
50 pub metadata: Arc<PluginMetadata>,
53
54 pub components: Vec<Arc<RemoteComponent>>,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
68#[serde(rename_all = "snake_case")]
69pub enum PluginRuntimeState {
70 #[serde(rename = "starting")]
72 Starting,
73 #[serde(rename = "running")]
75 Running,
76 #[serde(rename = "stopped")]
78 Stopped,
79 #[serde(rename = "crashed")]
81 Crashed,
82 #[serde(rename = "error")]
84 Error,
85 #[serde(rename = "unknown")]
87 #[default]
88 Unknown,
89}
90
91impl From<&ProcessState> for PluginRuntimeState {
92 fn from(state: &ProcessState) -> Self {
93 match state {
94 ProcessState::Starting => Self::Starting,
95 ProcessState::Running => Self::Running,
96 ProcessState::Stopped => Self::Stopped,
97 ProcessState::Crashed { .. } => Self::Crashed,
98 ProcessState::Error(_) => Self::Error,
99 }
100 }
101}
102
103struct PluginRestartContext {
105 manifest: Manifest,
106 plugin_dir: PathBuf,
107 host_call_handler: Arc<dyn HostCallHandler>,
108 crash_tx: mpsc::Sender<String>,
110 on_restart: RestartCallback,
114 on_crash: CrashCallback,
116 restart_count: AtomicU32,
119 locale: RwLock<String>,
121}
122
123impl std::fmt::Debug for PluginRestartContext {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 f.debug_struct("PluginRestartContext")
126 .field("manifest", &self.manifest)
127 .field("plugin_dir", &self.plugin_dir)
128 .field("host_call_handler", &self.host_call_handler)
129 .field("crash_tx", &self.crash_tx)
130 .field("on_restart", &"<RestartCallback>")
131 .field("restart_count", &self.restart_count)
132 .finish()
133 }
134}
135
136pub struct PluginHostManager {
138 pub processes: Arc<DashMap<String, Arc<PluginProcess>>>,
140 pub plugins: Arc<DashMap<String, PluginRegistration>>,
142 pub data_dir_root: PathBuf,
144 pub log_dir_root: PathBuf,
146 plugins_dir: PathBuf,
148 restart_contexts: Arc<DashMap<String, Arc<PluginRestartContext>>>,
150 load_lock: Arc<AsyncMutex<()>>,
152 builtin_component_ids: RwLock<HashSet<String>>,
158 self_arc: OnceLock<Arc<Self>>,
163}
164
165#[derive(Debug, thiserror::Error)]
167pub enum PluginLoadError {
168 #[error("manifest error: {0}")]
169 Manifest(String),
170 #[error("protocol error: {0}")]
171 Protocol(#[from] ProtocolError),
172 #[error("plugin already loaded: {0}")]
173 AlreadyLoaded(String),
174 #[error("plugin not found: {0}")]
175 NotFound(String),
176 #[error(
178 "component id collision for plugin {plugin_id}: '{component_id}' is already registered"
179 )]
180 ComponentIdCollision {
181 plugin_id: String,
183 component_id: String,
185 },
186}
187
188impl PluginHostManager {
189 pub fn new(plugins_dir: PathBuf, data_dir_root: PathBuf, log_dir_root: PathBuf) -> Arc<Self> {
190 let mgr = Arc::new(Self {
191 processes: Arc::new(DashMap::new()),
192 plugins: Arc::new(DashMap::new()),
193 data_dir_root,
194 log_dir_root,
195 plugins_dir,
196 restart_contexts: Arc::new(DashMap::new()),
197 load_lock: Arc::new(AsyncMutex::new(())),
198 builtin_component_ids: RwLock::new(HashSet::new()),
199 self_arc: OnceLock::new(),
200 });
201 if mgr.self_arc.set(Arc::clone(&mgr)).is_err() {
202 panic!("PluginHostManager self arc already set");
203 }
204 mgr
205 }
206
207 pub fn set_builtin_component_ids(&self, ids: HashSet<String>) {
212 *self.builtin_component_ids.write() = ids;
213 }
214
215 fn spawn_crash_loop(&self, crash_rx: mpsc::Receiver<String>) {
221 let mgr = Arc::clone(self.self_arc.get().expect("self arc set in load"));
222 tokio::spawn(async move {
223 crash_loop(mgr, crash_rx).await;
224 });
225 }
226
227 pub async fn load(
236 &self,
237 plugin_dir: &Path,
238 host_call_handler: Arc<dyn HostCallHandler>,
239 on_restart: RestartCallback,
240 on_crash: CrashCallback,
241 restart_count: u32,
242 locale: &str,
243 ) -> Result<PluginRegistration, PluginLoadError> {
244 let manifest_path = plugin_dir.join("manifest.toml");
245 let manifest_bytes = std::fs::read_to_string(&manifest_path)
246 .map_err(|e| PluginLoadError::Manifest(format!("cannot read manifest.toml: {}", e)))?;
247
248 let manifest: Manifest = toml::from_str(&manifest_bytes)
249 .map_err(|e| PluginLoadError::Manifest(format!("invalid manifest: {}", e)))?;
250
251 validate_manifest(&manifest, plugin_dir)?;
253
254 let plugin_id = manifest.plugin.id.clone();
255
256 let _guard = self.load_lock.lock().await;
258
259 if self.processes.contains_key(&plugin_id) {
261 return Err(PluginLoadError::AlreadyLoaded(plugin_id));
262 }
263
264 let data_dir = self.data_dir_root.join(&plugin_id);
265 let log_dir = self.log_dir_root.clone();
266
267 if let Err(e) = std::fs::create_dir_all(&data_dir) {
269 warn!(
270 "Failed to create plugin data dir {}: {}",
271 data_dir.display(),
272 e
273 );
274 }
275 if let Err(e) = std::fs::create_dir_all(&log_dir) {
276 warn!(
277 "Failed to create plugin log dir {}: {}",
278 log_dir.display(),
279 e
280 );
281 }
282
283 info!("Loading plugin {} from {}", plugin_id, plugin_dir.display());
284
285 let (crash_tx, crash_rx) = mpsc::channel::<String>(4);
287
288 let process = match PluginProcess::spawn(
290 &manifest,
291 plugin_dir,
292 &data_dir,
293 &log_dir,
294 host_call_handler.clone(),
295 crash_tx.clone(),
296 restart_count,
297 locale,
298 )
299 .await
300 {
301 Ok(p) => p,
302 Err(e) => {
303 return Err(e.into());
305 }
306 };
307
308 let client = process.client.clone();
310
311 self.restart_contexts.insert(
313 plugin_id.clone(),
314 Arc::new(PluginRestartContext {
315 manifest: manifest.clone(),
316 plugin_dir: plugin_dir.to_path_buf(),
317 host_call_handler,
318 crash_tx,
319 on_restart,
320 on_crash,
321 restart_count: AtomicU32::new(restart_count),
322 locale: RwLock::new(locale.to_string()),
323 }),
324 );
325
326 self.spawn_crash_loop(crash_rx);
328
329 let process = Arc::new(process);
333 self.processes.insert(plugin_id.clone(), process.clone());
334
335 let init_result = match process.discover_components().await {
337 Ok(result) => result,
338 Err(e) => {
339 self.shutdown_loaded_process(&plugin_id).await;
340 return Err(PluginLoadError::Protocol(e));
341 }
342 };
343
344 let adapters = build_components(&plugin_id, plugin_dir, &manifest, client, &init_result);
346
347 let component_ids = adapters
353 .components
354 .iter()
355 .map(|c| c.component_id().to_string())
356 .collect::<Vec<_>>();
357 if let Some(collision) =
358 find_component_id_collision(&component_ids, |id| self.component_id_is_taken(id))
359 {
360 error!(
361 "拒绝加载插件 {}:组件 id '{}' 已被其他已注册组件占用,清理进程并放弃加载",
362 plugin_id, collision
363 );
364 self.shutdown_loaded_process(&plugin_id).await;
365 return Err(PluginLoadError::ComponentIdCollision {
366 plugin_id,
367 component_id: collision,
368 });
369 }
370
371 let registered = adapters.clone();
373 self.plugins.insert(plugin_id.clone(), adapters);
374 Ok(registered)
375 }
376
377 pub fn plugins_dir(&self) -> &Path {
379 &self.plugins_dir
380 }
381
382 fn component_id_is_taken(&self, id: &str) -> bool {
387 let taken_by_plugin = self
388 .plugins
389 .iter()
390 .any(|e| e.value().components.iter().any(|c| c.component_id() == id));
391 taken_by_plugin || self.builtin_component_ids.read().contains(id)
392 }
393
394 pub async fn unload(&self, plugin_id: &str) -> Result<(), PluginLoadError> {
397 info!("Unloading plugin {}", plugin_id);
398 self.teardown(plugin_id).await;
399 for suffix in ["", ".1"] {
401 let log_file = self
402 .log_dir_root
403 .join(format!("{}.log{}", plugin_id, suffix));
404 let _ = std::fs::remove_file(&log_file);
405 }
406 Ok(())
407 }
408
409 async fn teardown(&self, plugin_id: &str) {
414 let _guard = self.load_lock.lock().await;
415 if let Some((_, ctx)) = self.restart_contexts.remove(plugin_id) {
417 ctx.host_call_handler.detach_event_sink();
418 }
419 self.shutdown_loaded_process(plugin_id).await;
420 self.plugins.remove(plugin_id);
421 }
422
423 async fn shutdown_loaded_process(&self, plugin_id: &str) {
427 if let Some((_, proc)) = self.processes.remove(plugin_id) {
428 match Arc::try_unwrap(proc) {
429 Ok(process) => process.shutdown(std::time::Duration::from_secs(5)).await,
430 Err(arc) => {
431 warn!(
432 "Plugin {} process Arc has {} strong references; forcing kill. \n This may indicate a leaked clone of the process handle.",
433 plugin_id,
434 Arc::strong_count(&arc)
435 );
436 arc.state.write().clone_from(&ProcessState::Stopped);
437 arc.client.close_transport();
438 if let Some(pid) = arc.pid {
439 force_kill_process(pid);
440 }
441 }
442 }
443 }
444 }
445
446 pub fn update_locale(&self, plugin_id: &str, locale: &str) {
452 if let Some(ctx) = self.restart_contexts.get_mut(plugin_id) {
453 *ctx.locale.write() = locale.to_string();
454 }
455 }
456
457 pub fn list_plugin_info(
462 &self,
463 enabled_fn: impl Fn(&PluginRegistration) -> bool,
464 ) -> Vec<InstalledPluginInfo> {
465 let mut result: Vec<InstalledPluginInfo> = self
466 .plugins
467 .iter()
468 .map(|entry| {
469 let registration = entry.value();
470 let process_state = self
471 .processes
472 .get(®istration.plugin_id)
473 .map(|p| PluginRuntimeState::from(&*p.state.read()))
474 .unwrap_or_default();
475 build_plugin_info(registration, process_state, enabled_fn(registration))
476 })
477 .collect();
478 result.sort_by_key(|p| (p.priority, p.plugin_id.clone()));
479 result
480 }
481
482 pub fn get_plugin_info(
486 &self,
487 plugin_id: &str,
488 enabled_fn: impl Fn(&PluginRegistration) -> bool,
489 ) -> Option<InstalledPluginInfo> {
490 let entry = self.plugins.get(plugin_id)?;
491 let registration = entry.value();
492 let process_state = self
493 .processes
494 .get(plugin_id)
495 .map(|p| PluginRuntimeState::from(&*p.state.read()))
496 .unwrap_or_default();
497 Some(build_plugin_info(
498 registration,
499 process_state,
500 enabled_fn(registration),
501 ))
502 }
503}
504
505fn build_plugin_info(
507 registration: &PluginRegistration,
508 process_state: PluginRuntimeState,
509 enabled: bool,
510) -> InstalledPluginInfo {
511 let priority = registration.metadata.priority;
514 InstalledPluginInfo {
515 plugin_id: registration.plugin_id.clone(),
516 name: registration.manifest.plugin.name.clone(),
517 version: registration.manifest.plugin.version.clone(),
518 description: registration.manifest.plugin.description.clone(),
519 author: registration.manifest.plugin.author.clone(),
520 state: process_state,
521 enabled,
522 kind: PluginKind::ThirdParty,
523 priority,
524 component_ids: registration
525 .components
526 .iter()
527 .map(|c| c.component_id().to_string())
528 .collect(),
529 hotkey: registration.metadata.hotkey.clone(),
530 icon: registration.metadata.icon.clone(),
531 mode: registration.metadata.mode,
532 }
533}
534
535#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
537pub struct InstalledPluginInfo {
538 #[serde(rename = "pluginId")]
539 pub plugin_id: String,
540 #[serde(rename = "name")]
541 pub name: String,
542 #[serde(rename = "version")]
543 pub version: String,
544 #[serde(rename = "description")]
545 pub description: String,
546 #[serde(rename = "author")]
547 pub author: String,
548 #[serde(rename = "state")]
549 pub state: PluginRuntimeState,
550 #[serde(rename = "enabled")]
551 pub enabled: bool,
552 #[serde(rename = "kind", default)]
554 pub kind: PluginKind,
555 #[serde(rename = "priority")]
556 pub priority: u32,
557 #[serde(rename = "componentIds", default)]
559 pub component_ids: Vec<String>,
560 #[serde(rename = "hotkey", default)]
562 pub hotkey: Option<String>,
563 #[serde(rename = "icon", default)]
566 pub icon: Option<String>,
567 #[serde(rename = "mode", default)]
570 pub mode: PluginMode,
571}
572
573fn validate_manifest(manifest: &Manifest, plugin_dir: &Path) -> Result<(), PluginLoadError> {
576 let id = &manifest.plugin.id;
577
578 static PLUGIN_ID_RE: OnceLock<regex::Regex> = OnceLock::new();
580 let re = PLUGIN_ID_RE.get_or_init(|| {
581 regex::Regex::new(zerolaunch_plugin_protocol::manifest::PLUGIN_ID_RE).unwrap()
582 });
583 if !re.is_match(id) {
584 return Err(PluginLoadError::Manifest(format!(
585 "invalid plugin id '{}': must match reverse domain",
586 id
587 )));
588 }
589
590 if semver::Version::parse(&manifest.plugin.version).is_err() {
592 return Err(PluginLoadError::Manifest(format!(
593 "invalid plugin version '{}'",
594 manifest.plugin.version
595 )));
596 }
597
598 if manifest.components.provides.is_empty() {
600 return Err(PluginLoadError::Manifest(
601 "components.provides must have at least one entry".into(),
602 ));
603 }
604
605 for p in &manifest.components.provides {
606 if !zerolaunch_plugin_protocol::manifest::REQUIRED_PROVIDES_VALUES.contains(&p.as_str()) {
607 return Err(PluginLoadError::Manifest(format!(
608 "unknown component type '{}'",
609 p
610 )));
611 }
612 }
613
614 let cmd_path = plugin_dir.join(&manifest.runtime.command);
616 let canonical_cmd = cmd_path
617 .canonicalize()
618 .map_err(|e| PluginLoadError::Manifest(format!("command not found: {}", e)))?;
619 let canonical_plugin_dir = plugin_dir
620 .canonicalize()
621 .map_err(|e| PluginLoadError::Manifest(format!("plugin dir canonicalize: {}", e)))?;
622 if !canonical_cmd.starts_with(&canonical_plugin_dir) {
623 return Err(PluginLoadError::Manifest(
624 "command path escapes plugin directory".into(),
625 ));
626 }
627
628 if let Some(icon) = &manifest.icon {
632 if let Ok(canonical_icon) = plugin_dir.join(&icon.path).canonicalize() {
633 if !canonical_icon.starts_with(&canonical_plugin_dir) {
634 return Err(PluginLoadError::Manifest(
635 "icon path escapes plugin directory".into(),
636 ));
637 }
638 }
639 }
640
641 Ok(())
642}
643
644fn read_plugin_icon(plugin_dir: &Path, icon_path: &str) -> Option<String> {
649 let icon_abs = plugin_dir.join(icon_path);
650 let canonical_icon = icon_abs.canonicalize().ok()?;
651 let canonical_plugin_dir = plugin_dir.canonicalize().ok()?;
652 if !canonical_icon.starts_with(&canonical_plugin_dir) {
654 warn!("插件图标路径逃逸插件目录,忽略: {}", icon_path);
655 return None;
656 }
657 let meta = std::fs::metadata(&canonical_icon).ok()?;
659 if meta.len() > crate::icon::MAX_ICON_BYTES {
660 warn!("插件图标超过 1MB 上限,忽略: {}", icon_path);
661 return None;
662 }
663 let bytes = std::fs::read(&canonical_icon).ok()?;
664 crate::icon::to_data_url(crate::icon::mime_from_extension(&canonical_icon), &bytes)
665}
666
667fn find_by_id<T: Clone + Default>(map: &[(String, T)], component_id: &str) -> T {
669 map.iter()
670 .find(|(id, _)| id == component_id)
671 .map(|(_, v)| v.clone())
672 .unwrap_or_default()
673}
674
675fn find_settings_value(
677 values: &[(String, serde_json::Value)],
678 component_id: &str,
679) -> serde_json::Value {
680 values
681 .iter()
682 .find(|(id, _)| id == component_id)
683 .map(|(_, v)| v.clone())
684 .unwrap_or(serde_json::Value::Null)
685}
686
687fn find_component_id_collision(
694 component_ids: &[String],
695 component_id_taken: impl Fn(&str) -> bool,
696) -> Option<String> {
697 component_ids
698 .iter()
699 .find(|id| component_id_taken(id))
700 .cloned()
701}
702
703fn build_components(
708 plugin_id: &str,
709 plugin_dir: &Path,
710 manifest: &Manifest,
711 client: Arc<crate::client::JsonRpcClient>,
712 init_result: &crate::process::InitResult,
713) -> PluginRegistration {
714 let plugin_metadata = PluginMetadata {
716 id: plugin_id.to_string(),
717 name: manifest.plugin.name.clone(),
718 version: manifest.plugin.version.clone(),
719 description: manifest.plugin.description.clone(),
720 author: manifest.plugin.author.clone(),
721 trigger_keywords: manifest.plugin.trigger_keywords.clone(),
722 trigger_description: manifest.plugin.trigger_description.clone(),
723 supported_os: manifest.plugin.supported_os.clone(),
724 priority: manifest.plugin.priority,
725 kind: PluginKind::ThirdParty,
727 hotkey: manifest.plugin.hotkey.clone(),
728 icon: manifest
730 .icon
731 .as_ref()
732 .and_then(|icon| read_plugin_icon(plugin_dir, &icon.path)),
733 mode: manifest.plugin.mode,
734 };
735 if plugin_metadata.hotkey.is_some() && plugin_metadata.mode != PluginMode::Panel {
737 warn!(
738 plugin_id = plugin_id,
739 declared = ?plugin_metadata.mode,
740 "插件清单声明热键但形态非 panel:热键仅对 panel 形态生效,该热键将不注册"
741 );
742 }
743 let plugin_metadata = Arc::new(plugin_metadata);
744
745 let components: Vec<Arc<RemoteComponent>> = init_result
746 .components
747 .iter()
748 .map(|comp| {
749 let schema = find_by_id(&init_result.settings_schemas, &comp.component_id);
750 let settings = find_settings_value(&init_result.settings_values, &comp.component_id);
751 let config_actions = find_by_id(&init_result.config_actions_map, &comp.component_id);
752 let default_enabled = find_by_id(&init_result.default_enabled_map, &comp.component_id);
753
754 let priority = comp.priority;
756
757 let kind = match &comp.kind {
758 ComponentKind::Plugin => {
759 let interaction_policy =
760 find_by_id(&init_result.interaction_policy_map, &comp.component_id);
761 RemoteComponentKind::Plugin {
762 metadata: Arc::clone(&plugin_metadata),
763 interaction_policy: parking_lot::RwLock::new(interaction_policy),
764 }
765 }
766 ComponentKind::DataSource => RemoteComponentKind::DataSource,
767 ComponentKind::ActionExecutor { target_types } => {
768 let result_actions =
769 find_by_id(&init_result.executor_actions_map, &comp.component_id);
770 RemoteComponentKind::ActionExecutor {
771 target_types: target_types.clone(),
772 result_actions,
773 }
774 }
775 ComponentKind::SearchEngine => RemoteComponentKind::SearchEngine,
776 ComponentKind::ScoreBooster => RemoteComponentKind::ScoreBooster,
777 ComponentKind::KeywordOptimizer => {
778 let info = init_result
782 .keyword_optimizer_info_map
783 .iter()
784 .find(|(id, _)| id == &comp.component_id)
785 .map(|(_, v)| v.clone())
786 .unwrap_or(KeywordOptimizerInfo {
787 input_source: KeywordInputSource::Refined,
788 priority,
789 });
790 RemoteComponentKind::KeywordOptimizer {
791 info: parking_lot::RwLock::new(info),
792 }
793 }
794 ComponentKind::KeywordInjector => RemoteComponentKind::KeywordInjector,
795 };
796
797 Arc::new(RemoteComponent::new(
798 comp.component_id.clone(),
799 comp.component_name.clone(),
800 comp.component_description.clone(),
801 comp.component_type,
802 priority,
803 client.clone(),
804 schema,
805 settings,
806 config_actions,
807 default_enabled,
808 kind,
809 ))
810 })
811 .collect();
812
813 let registration = PluginRegistration {
814 plugin_id: plugin_id.to_string(),
815 manifest: manifest.clone(),
816 plugin_dir: plugin_dir.to_path_buf(),
818 metadata: plugin_metadata,
819 components,
820 };
821
822 #[cfg(debug_assertions)]
823 {
824 for comp in ®istration.components {
827 if let RemoteComponentKind::Plugin { metadata, .. } = &comp.kind {
828 debug_assert!(
829 Arc::ptr_eq(metadata, ®istration.metadata),
830 "组件元数据与 PluginRegistration.metadata 必须同源共享(Arc::clone)"
831 );
832 }
833 }
834 }
835
836 registration
837}
838
839async fn crash_loop(mgr: Arc<PluginHostManager>, mut crash_rx: mpsc::Receiver<String>) {
843 while let Some(plugin_id) = crash_rx.recv().await {
844 handle_crash(&mgr, &plugin_id).await;
845 }
846}
847
848async fn handle_crash(mgr: &PluginHostManager, plugin_id: &str) {
853 warn!("Watchdog triggered restart for plugin: {}", plugin_id);
854
855 let prev = mgr.plugins.remove(plugin_id);
859 mgr.processes.remove(plugin_id);
860
861 let Some(ctx) = mgr
864 .restart_contexts
865 .get(plugin_id)
866 .map(|r| Arc::clone(r.value()))
867 else {
868 warn!(
869 "Crash notification for plugin '{}' but no restart context found — \
870 plugin may have been unloaded concurrently",
871 plugin_id
872 );
873 return;
874 };
875 ctx.host_call_handler.detach_event_sink();
877
878 if let Some((_, prev)) = prev {
879 (ctx.on_crash)(prev);
880 }
881 let new_count = ctx.restart_count.fetch_add(1, Ordering::SeqCst) + 1;
885 let max_restart = ctx.manifest.runtime.max_restart;
886 if new_count > max_restart {
887 error!(
888 "Plugin {} exceeded max restarts ({}/{}) — not restarting",
889 plugin_id, new_count, max_restart
890 );
891 mgr.restart_contexts.remove(plugin_id);
892 return;
893 }
894
895 let locale = ctx.locale.read().clone();
901 match mgr
902 .load(
903 &ctx.plugin_dir,
904 ctx.host_call_handler.clone(),
905 ctx.on_restart.clone(),
906 ctx.on_crash.clone(),
907 new_count,
908 &locale,
909 )
910 .await
911 {
912 Ok(registered) => {
913 (ctx.on_restart)(registered).await;
915 info!("Plugin {} successfully restarted", plugin_id);
916 }
917 Err(e) => {
918 error!("Failed to restart plugin {}: {}", plugin_id, e);
919 mgr.restart_contexts.remove(plugin_id);
920 }
921 }
922}
923
924#[cfg(test)]
925mod tests {
926 use super::find_component_id_collision;
927
928 #[test]
930 fn collision_check_finds_first_taken_id() {
931 let ids = vec!["free".to_string(), "taken".to_string(), "other".to_string()];
932 let taken = |id: &str| id == "taken";
933 assert_eq!(
934 find_component_id_collision(&ids, taken),
935 Some("taken".to_string())
936 );
937 }
938
939 #[test]
941 fn collision_check_returns_none_when_all_free() {
942 let ids = vec!["a".to_string(), "b".to_string()];
943 assert_eq!(find_component_id_collision(&ids, |_| false), None);
944 }
945}