optirs_core/plugin/registry.rs
1// Plugin registry for managing and discovering optimizer plugins
2//
3// This module provides a centralized registry system for managing optimizer plugins,
4// including registration, discovery, loading, and version management.
5
6use super::core::*;
7use crate::error::{OptimError, Result};
8use scirs2_core::numeric::Float;
9use std::any::Any;
10use std::collections::HashMap;
11use std::fmt::Debug;
12use std::path::{Path, PathBuf};
13use std::sync::{Mutex, MutexGuard, RwLock, RwLockReadGuard, RwLockWriteGuard};
14
15/// Take a read lock, recovering from poisoning instead of propagating the
16/// panic. A panic inside one plugin call (which runs while these locks are
17/// held) must never permanently brick the process-wide registry: the data
18/// behind these locks stays structurally consistent even if one accessor
19/// panicked partway through a call, since every mutation here is a single
20/// insert/remove/assign with no multi-step invariant spanning the guard.
21fn read_lock<T>(lock: &RwLock<T>) -> RwLockReadGuard<'_, T> {
22 lock.read().unwrap_or_else(|poisoned| poisoned.into_inner())
23}
24
25/// Take a write lock, recovering from poisoning. See [`read_lock`].
26fn write_lock<T>(lock: &RwLock<T>) -> RwLockWriteGuard<'_, T> {
27 lock.write()
28 .unwrap_or_else(|poisoned| poisoned.into_inner())
29}
30
31/// Take a mutex lock, recovering from poisoning. See [`read_lock`].
32fn mutex_lock<T>(lock: &Mutex<T>) -> MutexGuard<'_, T> {
33 lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
34}
35
36/// Parse a `major.minor.patch` prefix (ignoring any `-pre`/`+build` suffix,
37/// per semver's separator rules) into a numeric triplet. `None` when the
38/// string does not start with a dotted numeric version.
39fn parse_version_triplet(version: &str) -> Option<(u64, u64, u64)> {
40 let core = version.split(['-', '+']).next().unwrap_or(version);
41 let mut parts = core.split('.');
42 let major = parts.next()?.parse().ok()?;
43 let minor = parts.next().unwrap_or("0").parse().ok()?;
44 let patch = parts.next().unwrap_or("0").parse().ok()?;
45 Some((major, minor, patch))
46}
47
48/// Compare two version strings numerically by `(major, minor, patch)` when
49/// both parse as dotted numeric versions (this crate has no `semver`
50/// dependency, so pre-release/build metadata ordering is not modelled).
51/// Falls back to a byte-lexicographic comparison for non-numeric version
52/// strings so callers still get a total order rather than a panic.
53///
54/// Byte-lexicographic comparison alone is wrong for numeric versions --
55/// `"0.10.0" < "0.9.0"` and `"1.10.0" < "1.9.0"` under `str`'s `Ord`, so an
56/// ecosystem that ever reaches a double-digit minor or patch would silently
57/// mis-resolve plugin version requirements.
58fn version_cmp(a: &str, b: &str) -> std::cmp::Ordering {
59 match (parse_version_triplet(a), parse_version_triplet(b)) {
60 (Some(va), Some(vb)) => va.cmp(&vb),
61 _ => a.cmp(b),
62 }
63}
64
65/// Central plugin registry for managing all optimizer plugins
66#[derive(Debug)]
67pub struct PluginRegistry {
68 /// Registered plugin factories
69 factories: RwLock<HashMap<String, PluginRegistration>>,
70 /// Plugin search paths
71 search_paths: RwLock<Vec<PathBuf>>,
72 /// Registry configuration
73 config: RegistryConfig,
74 /// Plugin cache
75 cache: Mutex<PluginCache>,
76 /// Event listeners
77 event_listeners: RwLock<Vec<Box<dyn RegistryEventListener>>>,
78}
79
80/// Plugin registration entry
81#[derive(Debug)]
82pub struct PluginRegistration {
83 /// Plugin factory
84 pub factory: Box<dyn PluginFactoryWrapper>,
85 /// Plugin metadata
86 pub info: PluginInfo,
87 /// Capabilities declared by the factory at registration time, used to
88 /// enforce `PluginQuery::required_capabilities` in `matches_query`.
89 pub capabilities: PluginCapabilities,
90 /// Registration timestamp
91 pub registered_at: std::time::SystemTime,
92 /// Plugin status
93 pub status: PluginStatus,
94 /// Load count
95 pub load_count: usize,
96 /// Last used timestamp
97 pub last_used: Option<std::time::SystemTime>,
98}
99
100/// Wrapper trait for type-erased plugin factories
101pub trait PluginFactoryWrapper: Debug + Send + Sync {
102 /// Create optimizer with f32 precision
103 fn create_f32(&self, config: OptimizerConfig) -> Result<Box<dyn OptimizerPlugin<f32>>>;
104
105 /// Create optimizer with f64 precision
106 fn create_f64(&self, config: OptimizerConfig) -> Result<Box<dyn OptimizerPlugin<f64>>>;
107
108 /// Get factory information
109 fn info(&self) -> PluginInfo;
110
111 /// Get the capabilities the produced optimizer declares. Backed by a
112 /// default so existing `PluginFactoryWrapper` implementors outside this
113 /// crate keep compiling; the default reports every capability absent
114 /// (`PluginCapabilities::default()` is all-`false`), which is the safe
115 /// direction to fail in for `PluginQuery::required_capabilities`
116 /// filtering -- an unimplemented override under-promises rather than
117 /// over-promising what the plugin can do.
118 fn capabilities(&self) -> PluginCapabilities {
119 PluginCapabilities::default()
120 }
121
122 /// Validate configuration
123 fn validate_config(&self, config: &OptimizerConfig) -> Result<()>;
124
125 /// Get default configuration
126 fn default_config(&self) -> OptimizerConfig;
127
128 /// Get configuration schema
129 fn config_schema(&self) -> ConfigSchema;
130
131 /// Check if factory supports the given data type
132 fn supports_type(&self, datatype: &DataType) -> bool;
133}
134
135/// Plugin status
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub enum PluginStatus {
138 /// Plugin is active and available
139 Active,
140 /// Plugin is disabled
141 Disabled,
142 /// Plugin failed to load
143 Failed(String),
144 /// Plugin is deprecated
145 Deprecated,
146 /// Plugin is in maintenance mode
147 Maintenance,
148}
149
150/// Registry configuration
151#[derive(Debug, Clone)]
152pub struct RegistryConfig {
153 /// Enable automatic plugin discovery
154 pub auto_discovery: bool,
155 /// Enable plugin validation on registration
156 pub validate_on_registration: bool,
157 /// Enable plugin caching. When `false`, [`PluginRegistry::create_optimizer`]
158 /// never reads or writes [`PluginCache`] -- every call reaches the
159 /// plugin factory, and `get_cache_stats()` stays at all-zero. See
160 /// [`PluginCache`] for the (f64-only) scope of what caching covers.
161 pub enable_caching: bool,
162 /// Maximum number of distinct plugin names [`PluginCache`] holds at
163 /// once. Once reached, inserting a new entry evicts the
164 /// least-recently-used one first (see [`PluginCache`]). `0` means
165 /// "cache nothing": caching stays logically enabled (bypass is still
166 /// controlled solely by `enable_caching`) but every lookup misses and
167 /// nothing is ever retained.
168 pub max_cache_size: usize,
169 /// Plugin load timeout
170 pub load_timeout: std::time::Duration,
171 /// Enable plugin sandboxing (future feature)
172 pub enable_sandboxing: bool,
173 /// Allowed plugin sources
174 pub allowed_sources: Vec<PluginSource>,
175}
176
177/// Plugin source types
178#[derive(Debug, Clone)]
179pub enum PluginSource {
180 /// Built-in plugins
181 BuiltIn,
182 /// Local filesystem
183 Local(PathBuf),
184 /// Remote repository
185 Remote(String),
186 /// Package manager
187 Package(String),
188}
189
190/// Plugin cache for performance optimization.
191///
192/// # Scope: `f64` only
193///
194/// `CachedPlugin::plugin` is monomorphized to `Box<dyn OptimizerPlugin<f64>>`
195/// -- there is no generic `PluginCache<A>`. [`PluginRegistry::create_optimizer::<A>`]
196/// only consults this cache when `A = f64`; a call with `A = f32` (or any
197/// other `Float` impl) always goes straight to the factory and never
198/// touches `instances`, `stats.hits`, or `stats.misses`. This is a
199/// deliberate scope limitation, not an oversight: caching f32 instances
200/// too would need either a second, separately-bounded `HashMap` or an
201/// `Any`-erased value type, and nothing in this crate currently creates
202/// enough f32 optimizers through the registry to justify that complexity.
203///
204/// # Cache key and correctness
205///
206/// Entries are keyed by plugin *name*, but a lookup is only a hit when the
207/// caller's [`OptimizerConfig`] also equals the config the cached instance
208/// was built with (`CachedPlugin::config`). A name-only key would let a
209/// caller requesting e.g. a different `learning_rate` silently receive an
210/// instance built with someone else's config -- that would be exactly the
211/// kind of fabricated-success this crate's stub-removal pass exists to
212/// eliminate, so a config mismatch is treated as a miss (the stale entry is
213/// replaced by a freshly created one) rather than returned.
214///
215/// # Eviction
216///
217/// Bounded by [`RegistryConfig::max_cache_size`]: inserting past the limit
218/// evicts the least-recently-used entry first. "Recently used" is tracked
219/// with a monotonically increasing `u64` sequence number bumped on every
220/// insert and every hit, not a wall-clock timestamp -- two cache
221/// operations completing within the same clock tick (common on fast
222/// hardware or under `#[test]`) would otherwise tie under
223/// `SystemTime`-based LRU and evict a nondeterministically-chosen entry.
224#[derive(Debug)]
225pub struct PluginCache {
226 /// Cached plugin instances, keyed by plugin name.
227 instances: HashMap<String, CachedPlugin>,
228 /// Cache statistics
229 stats: CacheStats,
230 /// Source of the next `CachedPlugin::sequence` value; incremented on
231 /// every insert and every hit so eviction has a real, deterministic
232 /// "least recently used" ordering (see the struct-level doc comment).
233 next_sequence: u64,
234}
235
236/// Cached plugin instance
237#[derive(Debug)]
238pub struct CachedPlugin {
239 /// Plugin instance
240 pub plugin: Box<dyn OptimizerPlugin<f64>>,
241 /// The exact [`OptimizerConfig`] this instance was created with. A
242 /// lookup with a different config is treated as a miss -- see
243 /// [`PluginCache`]'s "Cache key and correctness" section.
244 pub config: OptimizerConfig,
245 /// Cache timestamp
246 pub cached_at: std::time::SystemTime,
247 /// Access count
248 pub access_count: usize,
249 /// Last accessed
250 pub last_accessed: std::time::SystemTime,
251 /// Recency ordinal used for LRU eviction; see [`PluginCache::next_sequence`].
252 pub(super) sequence: u64,
253}
254
255/// Cache statistics
256#[derive(Debug, Default, Clone)]
257pub struct CacheStats {
258 /// Total cache hits
259 pub hits: usize,
260 /// Total cache misses (an f64 `create_optimizer` call that reached the
261 /// factory: no matching cached entry existed, or its config differed)
262 pub misses: usize,
263 /// Total evictions
264 pub evictions: usize,
265 /// Approximate memory used by currently cached instances, in bytes.
266 /// Computed as `sum(size_of_val(&*cached.plugin))` -- the real,
267 /// runtime size of each cached optimizer's own concrete struct
268 /// (resolved through its vtable, not guessed). This deliberately does
269 /// **not** account for any heap allocations *inside* that struct
270 /// (e.g. a `Vec<f64>` momentum buffer): this crate has no allocator
271 /// instrumentation to attribute those bytes, and reporting only the
272 /// immediate struct size is an honest undercount rather than a
273 /// fabricated total.
274 pub memory_used: usize,
275}
276
277/// Registry event listener trait
278pub trait RegistryEventListener: Debug + Send + Sync {
279 /// Called when a plugin is registered
280 fn on_plugin_registered(&mut self, _info: &PluginInfo) {}
281
282 /// Called when a plugin is unregistered
283 fn on_plugin_unregistered(&mut self, _name: &str) {}
284
285 /// Called when a plugin is loaded
286 fn on_plugin_loaded(&mut self, _name: &str) {}
287
288 /// Called when a plugin fails to load
289 fn on_plugin_load_failed(&mut self, _name: &str, _error: &str) {}
290
291 /// Called when a plugin is enabled/disabled
292 fn on_plugin_status_changed(&mut self, _name: &str, _status: &PluginStatus) {}
293}
294
295/// Plugin search query
296#[derive(Debug, Clone, Default)]
297pub struct PluginQuery {
298 /// Plugin name pattern
299 pub name_pattern: Option<String>,
300 /// Plugin category filter
301 pub category: Option<PluginCategory>,
302 /// Required capabilities
303 pub required_capabilities: Vec<String>,
304 /// Supported data types
305 pub data_types: Vec<DataType>,
306 /// Version requirements
307 pub version_requirements: Option<VersionRequirement>,
308 /// Tags filter
309 pub tags: Vec<String>,
310 /// Maximum results
311 pub limit: Option<usize>,
312}
313
314/// Version requirement specification
315#[derive(Debug, Clone)]
316pub struct VersionRequirement {
317 /// Minimum version (inclusive)
318 pub min_version: Option<String>,
319 /// Maximum version (exclusive)
320 pub max_version: Option<String>,
321 /// Exact version match
322 pub exact_version: Option<String>,
323}
324
325/// Plugin search result
326#[derive(Debug, Clone)]
327pub struct PluginSearchResult {
328 /// Matching plugins
329 pub plugins: Vec<PluginInfo>,
330 /// Total count (before limit)
331 pub total_count: usize,
332 /// Search query used
333 pub query: PluginQuery,
334 /// Search execution time
335 pub search_time: std::time::Duration,
336}
337
338impl PluginRegistry {
339 /// Create a new plugin registry
340 pub fn new(config: RegistryConfig) -> Self {
341 Self {
342 factories: RwLock::new(HashMap::new()),
343 search_paths: RwLock::new(Vec::new()),
344 config,
345 cache: Mutex::new(PluginCache::new()),
346 event_listeners: RwLock::new(Vec::new()),
347 }
348 }
349
350 /// Get the global plugin registry instance
351 pub fn global() -> &'static Self {
352 static INSTANCE: std::sync::OnceLock<PluginRegistry> = std::sync::OnceLock::new();
353 INSTANCE.get_or_init(|| {
354 let config = RegistryConfig::default();
355 let mut registry = PluginRegistry::new(config);
356 registry.register_builtin_plugins();
357 registry
358 })
359 }
360
361 /// Register a plugin factory
362 pub fn register_plugin<F>(&self, factory: F) -> Result<()>
363 where
364 F: PluginFactoryWrapper + 'static,
365 {
366 let info = factory.info();
367 let name = info.name.clone();
368
369 // Validate plugin if enabled
370 if self.config.validate_on_registration {
371 self.validate_plugin(&factory)?;
372 }
373
374 let capabilities = factory.capabilities();
375 let registration = PluginRegistration {
376 factory: Box::new(factory),
377 info: info.clone(),
378 capabilities,
379 registered_at: std::time::SystemTime::now(),
380 status: PluginStatus::Active,
381 load_count: 0,
382 last_used: None,
383 };
384
385 {
386 let mut factories = write_lock(&self.factories);
387 factories.insert(name.clone(), registration);
388 }
389
390 // Notify event listeners
391 {
392 let mut listeners = write_lock(&self.event_listeners);
393 for listener in listeners.iter_mut() {
394 listener.on_plugin_registered(&info);
395 }
396 }
397
398 Ok(())
399 }
400
401 /// Unregister a plugin
402 pub fn unregister_plugin(&self, name: &str) -> Result<()> {
403 let mut factories = write_lock(&self.factories);
404 if factories.remove(name).is_some() {
405 // Notify event listeners
406 drop(factories);
407 let mut listeners = write_lock(&self.event_listeners);
408 for listener in listeners.iter_mut() {
409 listener.on_plugin_unregistered(name);
410 }
411 Ok(())
412 } else {
413 Err(OptimError::PluginNotFound(name.to_string()))
414 }
415 }
416
417 /// Create optimizer instance from plugin
418 pub fn create_optimizer<A>(
419 &self,
420 name: &str,
421 config: OptimizerConfig,
422 ) -> Result<Box<dyn OptimizerPlugin<A>>>
423 where
424 A: Float + Debug + Send + Sync + 'static,
425 {
426 // A single write guard covers status check, validation, creation,
427 // and the load_count/last_used update -- there is no read-then-
428 // reacquire-as-write gap for another thread to unregister the
429 // plugin (or race a concurrent `create_optimizer` call) in between.
430 // The previous version dropped its read lock and reacquired a write
431 // lock purely to bump the usage counters, so `factories.get_mut(name)`
432 // could silently find nothing if the plugin was unregistered in
433 // that window -- the statistics update for an otherwise-successful
434 // creation would vanish with no error. Third-party factory code
435 // still runs under `catch_unwind` (as before), so a panicking
436 // plugin cannot poison this exclusive lock either.
437 let mut factories = write_lock(&self.factories);
438 let registration = factories
439 .get(name)
440 .ok_or_else(|| OptimError::PluginNotFound(name.to_string()))?;
441
442 // Check plugin status
443 match registration.status {
444 PluginStatus::Active => {}
445 PluginStatus::Disabled => {
446 return Err(OptimError::PluginDisabled(name.to_string()));
447 }
448 PluginStatus::Failed(ref error) => {
449 return Err(OptimError::PluginLoadError(error.clone()));
450 }
451 PluginStatus::Deprecated => {
452 // Log warning but continue
453 log::warn!("Plugin '{}' is deprecated", name);
454 }
455 PluginStatus::Maintenance => {
456 return Err(OptimError::PluginInMaintenance(name.to_string()));
457 }
458 }
459
460 // Validate configuration
461 registration.factory.validate_config(&config)?;
462
463 // Create optimizer based on type. Third-party factory code runs here
464 // while `factories` is held write-locked, so a panic is caught
465 // rather than allowed to poison the registry-wide lock.
466 let optimizer = if std::any::TypeId::of::<A>() == std::any::TypeId::of::<f32>() {
467 // `PluginCache` is monomorphized to `Box<dyn OptimizerPlugin<f64>>`
468 // (see its doc comment) and so cannot represent an f32 instance
469 // at all -- this branch always reaches the factory, regardless
470 // of `enable_caching`.
471 let opt = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
472 registration.factory.create_f32(config)
473 }))
474 .map_err(|_| {
475 OptimError::PluginLoadError(format!(
476 "plugin '{name}' panicked while creating an f32 optimizer"
477 ))
478 })??;
479 // Safe downcast: A and f32 are the same type here (proven by the
480 // TypeId check above), so boxing `opt` into `dyn Any` and
481 // downcasting to `Box<dyn OptimizerPlugin<A>>` succeeds via
482 // ordinary `Any` machinery -- no `transmute` of a trait object
483 // (whose fat-pointer/vtable layout across distinct generic
484 // instantiations is not guaranteed) is required.
485 let boxed_any: Box<dyn Any> = Box::new(opt);
486 *boxed_any
487 .downcast::<Box<dyn OptimizerPlugin<A>>>()
488 .map_err(|_| {
489 OptimError::UnsupportedDataType(
490 "internal error: f32 downcast failed".to_string(),
491 )
492 })?
493 } else if std::any::TypeId::of::<A>() == std::any::TypeId::of::<f64>() {
494 let use_cache = self.config.enable_caching;
495
496 // A cache hit must match on *both* plugin name and config (see
497 // `PluginCache`'s "Cache key and correctness" doc section), so
498 // the lookup runs before the factory call and can skip it
499 // entirely on a hit -- `get_or_record_miss` also folds in the
500 // `stats.misses` bump for every other outcome.
501 let cached_hit = if use_cache {
502 let mut cache = mutex_lock(&self.cache);
503 cache.get_or_record_miss(name, &config)?
504 } else {
505 None
506 };
507
508 let opt_f64: Box<dyn OptimizerPlugin<f64>> = if let Some(hit) = cached_hit {
509 hit
510 } else {
511 // Only clone `config` when it will actually be stored --
512 // avoids the clone entirely when caching is disabled.
513 let config_for_cache = use_cache.then(|| config.clone());
514 let created = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
515 registration.factory.create_f64(config)
516 }))
517 .map_err(|_| {
518 OptimError::PluginLoadError(format!(
519 "plugin '{name}' panicked while creating an f64 optimizer"
520 ))
521 })??;
522 if let Some(cache_config) = config_for_cache {
523 // Same rationale as `get_or_record_miss`: `clone_plugin`
524 // is third-party code, called here under `factories`'s
525 // write lock, so a panic must be caught rather than
526 // allowed to unwind through it.
527 let created_ref = &created;
528 let cloned_for_cache =
529 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
530 created_ref.clone_plugin()
531 }))
532 .map_err(|_| {
533 OptimError::PluginLoadError(format!(
534 "plugin '{name}' panicked while cloning a newly created f64 \
535 optimizer for the cache"
536 ))
537 })?;
538 let mut cache = mutex_lock(&self.cache);
539 cache.insert(
540 name.to_string(),
541 cloned_for_cache,
542 cache_config,
543 self.config.max_cache_size,
544 );
545 }
546 created
547 };
548
549 let boxed_any: Box<dyn Any> = Box::new(opt_f64);
550 *boxed_any
551 .downcast::<Box<dyn OptimizerPlugin<A>>>()
552 .map_err(|_| {
553 OptimError::UnsupportedDataType(
554 "internal error: f64 downcast failed".to_string(),
555 )
556 })?
557 } else {
558 return Err(OptimError::UnsupportedDataType(format!(
559 "Type {} not supported",
560 std::any::type_name::<A>()
561 )));
562 };
563
564 // Update usage statistics under the same write guard used to read
565 // and create -- no reacquisition, so this entry cannot have been
566 // removed since the lookup above.
567 if let Some(registration) = factories.get_mut(name) {
568 registration.load_count += 1;
569 registration.last_used = Some(std::time::SystemTime::now());
570 }
571
572 // Notify event listeners
573 drop(factories);
574 let mut listeners = write_lock(&self.event_listeners);
575 for listener in listeners.iter_mut() {
576 listener.on_plugin_loaded(name);
577 }
578
579 Ok(optimizer)
580 }
581
582 /// List all registered plugins
583 pub fn list_plugins(&self) -> Vec<PluginInfo> {
584 let factories = read_lock(&self.factories);
585 factories.values().map(|reg| reg.info.clone()).collect()
586 }
587
588 /// Search for plugins matching criteria
589 pub fn search_plugins(&self, query: PluginQuery) -> PluginSearchResult {
590 let start_time = std::time::Instant::now();
591 let factories = read_lock(&self.factories);
592
593 let mut matching_plugins = Vec::new();
594
595 for registration in factories.values() {
596 if self.matches_query(®istration.info, ®istration.capabilities, &query) {
597 matching_plugins.push(registration.info.clone());
598 }
599 }
600
601 let total_count = matching_plugins.len();
602
603 // Apply limit if specified
604 if let Some(limit) = query.limit {
605 matching_plugins.truncate(limit);
606 }
607
608 let search_time = start_time.elapsed();
609
610 PluginSearchResult {
611 plugins: matching_plugins,
612 total_count,
613 query,
614 search_time,
615 }
616 }
617
618 /// Get plugin information
619 pub fn get_plugin_info(&self, name: &str) -> Option<PluginInfo> {
620 let factories = read_lock(&self.factories);
621 factories.get(name).map(|reg| reg.info.clone())
622 }
623
624 /// Get plugin status
625 pub fn get_plugin_status(&self, name: &str) -> Option<PluginStatus> {
626 let factories = read_lock(&self.factories);
627 factories.get(name).map(|reg| reg.status.clone())
628 }
629
630 /// Enable/disable plugin
631 pub fn set_plugin_status(&self, name: &str, status: PluginStatus) -> Result<()> {
632 let mut factories = write_lock(&self.factories);
633 let registration = factories
634 .get_mut(name)
635 .ok_or_else(|| OptimError::PluginNotFound(name.to_string()))?;
636
637 let old_status = registration.status.clone();
638 registration.status = status.clone();
639
640 // Notify event listeners if status changed
641 if old_status != status {
642 drop(factories);
643 let mut listeners = write_lock(&self.event_listeners);
644 for listener in listeners.iter_mut() {
645 listener.on_plugin_status_changed(name, &status);
646 }
647 }
648
649 Ok(())
650 }
651
652 /// Add plugin search path
653 pub fn add_search_path<P: AsRef<Path>>(&self, path: P) {
654 let mut search_paths = write_lock(&self.search_paths);
655 search_paths.push(path.as_ref().to_path_buf());
656 }
657
658 /// Discover plugins in search paths
659 pub fn discover_plugins(&self) -> Result<usize> {
660 if !self.config.auto_discovery {
661 return Ok(0);
662 }
663
664 let search_paths = read_lock(&self.search_paths);
665 let mut discovered_count = 0;
666
667 for path in search_paths.iter() {
668 if path.exists() && path.is_dir() {
669 discovered_count += self.discover_plugins_in_directory(path)?;
670 }
671 }
672
673 Ok(discovered_count)
674 }
675
676 /// Add event listener
677 pub fn add_event_listener(&self, listener: Box<dyn RegistryEventListener>) {
678 let mut listeners = write_lock(&self.event_listeners);
679 listeners.push(listener);
680 }
681
682 /// Get cache statistics
683 pub fn get_cache_stats(&self) -> CacheStats {
684 let cache = mutex_lock(&self.cache);
685 cache.stats.clone()
686 }
687
688 /// Clear plugin cache
689 pub fn clear_cache(&self) {
690 let mut cache = mutex_lock(&self.cache);
691 cache.instances.clear();
692 cache.stats = CacheStats::default();
693 }
694
695 // Private helper methods
696
697 fn validate_plugin(&self, factory: &dyn PluginFactoryWrapper) -> Result<()> {
698 // Basic validation - check if plugin can be created
699 let config = factory.default_config();
700 let _optimizer = factory.create_f64(config)?;
701 Ok(())
702 }
703
704 fn matches_query(
705 &self,
706 info: &PluginInfo,
707 capabilities: &PluginCapabilities,
708 query: &PluginQuery,
709 ) -> bool {
710 // Check name pattern
711 if let Some(ref pattern) = query.name_pattern {
712 if !info.name.contains(pattern) {
713 return false;
714 }
715 }
716
717 // Check category
718 if let Some(ref category) = query.category {
719 if info.category != *category {
720 return false;
721 }
722 }
723
724 // Check data types
725 if !query.data_types.is_empty() {
726 let has_common_type = query
727 .data_types
728 .iter()
729 .any(|dt| info.supported_types.contains(dt));
730 if !has_common_type {
731 return false;
732 }
733 }
734
735 // Check tags
736 if !query.tags.is_empty() {
737 let has_common_tag = query.tags.iter().any(|tag| info.tags.contains(tag));
738 if !has_common_tag {
739 return false;
740 }
741 }
742
743 // Check version requirements
744 if let Some(ref version_req) = query.version_requirements {
745 if !self.version_matches(&info.version, version_req) {
746 return false;
747 }
748 }
749
750 // Check required capabilities: every named capability must be
751 // declared `true` by the plugin, or it is excluded from the
752 // results. Previously this field was declared on `PluginQuery` and
753 // never consulted at all, so a caller searching for e.g.
754 // `["gpu_support"]` got back plugins that do not support GPUs.
755 if !query
756 .required_capabilities
757 .iter()
758 .all(|cap| capabilities.has_capability(cap))
759 {
760 return false;
761 }
762
763 true
764 }
765
766 fn version_matches(&self, version: &str, requirement: &VersionRequirement) -> bool {
767 if let Some(ref exact) = requirement.exact_version {
768 return version == exact;
769 }
770
771 if let Some(ref min) = requirement.min_version {
772 if version_cmp(version, min) == std::cmp::Ordering::Less {
773 return false;
774 }
775 }
776
777 if let Some(ref max) = requirement.max_version {
778 if version_cmp(version, max) != std::cmp::Ordering::Less {
779 return false;
780 }
781 }
782
783 true
784 }
785
786 /// Recursively count candidate plugin files under `path` (same
787 /// extension/name convention as `PluginLoader::is_plugin_file`: shared
788 /// libraries, or a `plugin.toml` manifest).
789 ///
790 /// This crate has no dynamic-loading backend (see the module-level note
791 /// in `plugin::loader` on why `dlopen`/`libloading` is not wired up),
792 /// so a discovered file cannot actually be turned into a registered
793 /// `PluginRegistration` here -- previously this returned a hardcoded
794 /// `Ok(0)` regardless of what was on disk, which reads identically to
795 /// "no plugins present" and "discovery is unimplemented". Returning the
796 /// real count at least tells a caller the truth about what discovery
797 /// *found*, even though loading them still requires
798 /// `PluginRegistry::register_plugin` with a statically compiled
799 /// factory.
800 fn discover_plugins_in_directory(&self, path: &Path) -> Result<usize> {
801 let mut count = 0;
802 for entry in std::fs::read_dir(path)? {
803 let entry = entry?;
804 let entry_path = entry.path();
805 if entry_path.is_dir() {
806 count += self.discover_plugins_in_directory(&entry_path)?;
807 continue;
808 }
809 let is_candidate = match entry_path.extension().and_then(|e| e.to_str()) {
810 Some("so") | Some("dylib") | Some("dll") => true,
811 _ => entry_path.file_name().and_then(|n| n.to_str()) == Some("plugin.toml"),
812 };
813 if is_candidate {
814 count += 1;
815 }
816 }
817 Ok(count)
818 }
819
820 /// Register any statically-compiled built-in plugins. There are
821 /// currently none shipped with this crate -- optimizers ship as their
822 /// own `OptimizerPlugin` implementations registered directly by the
823 /// caller via `register_plugin`, not as a fixed built-in set -- so this
824 /// legitimately has nothing to do. Kept as an explicit extension point
825 /// (and call site in `global()`) rather than removed, so adding a
826 /// future built-in plugin is a one-line change here.
827 fn register_builtin_plugins(&mut self) {}
828}
829
830impl PluginCache {
831 fn new() -> Self {
832 Self {
833 instances: HashMap::new(),
834 stats: CacheStats::default(),
835 next_sequence: 0,
836 }
837 }
838
839 /// Look up a cached instance for `name`. Only a hit when `config`
840 /// equals the config the cached instance was built with (see
841 /// [`PluginCache`]'s "Cache key and correctness" doc section); any
842 /// other outcome -- no entry, or a config mismatch -- is a miss and
843 /// bumps `stats.misses`. On a hit, returns an independent clone (via
844 /// [`OptimizerPlugin::clone_plugin`]) so the caller gets an owned
845 /// instance while the cache keeps its own, and refreshes the entry's
846 /// recency so it is not the next eviction candidate.
847 ///
848 /// `clone_plugin` is third-party trait code -- the registered plugin
849 /// author's own impl, not anything this crate controls -- called here
850 /// while both the caller's `factories` write lock and this cache's own
851 /// mutex are held. A panic inside it is caught the same way the
852 /// adjacent factory-call sites in `create_optimizer` already catch
853 /// `create_f32`/`create_f64` panics, so it surfaces as `Err` instead of
854 /// unwinding through two held locks. The poisoned entry is evicted
855 /// (bumping `stats.evictions`) rather than left cached: recency was
856 /// already refreshed above, so leaving it in place would make this
857 /// plugin name return `Err` on every subsequent call forever instead
858 /// of just this one, once a broken `clone_plugin` demonstrated it
859 /// cannot be trusted.
860 fn get_or_record_miss(
861 &mut self,
862 name: &str,
863 config: &OptimizerConfig,
864 ) -> Result<Option<Box<dyn OptimizerPlugin<f64>>>> {
865 if let Some(entry) = self.instances.get_mut(name) {
866 if &entry.config == config {
867 self.next_sequence += 1;
868 entry.access_count += 1;
869 entry.last_accessed = std::time::SystemTime::now();
870 entry.sequence = self.next_sequence;
871
872 let plugin = &entry.plugin;
873 let clone_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
874 plugin.clone_plugin()
875 }));
876
877 return match clone_result {
878 Ok(cloned) => {
879 self.stats.hits += 1;
880 Ok(Some(cloned))
881 }
882 Err(_) => {
883 self.instances.remove(name);
884 self.stats.evictions += 1;
885 Err(OptimError::PluginLoadError(format!(
886 "plugin '{name}' panicked while cloning a cached f64 optimizer; \
887 the poisoned cache entry has been evicted"
888 )))
889 }
890 };
891 }
892 }
893 self.stats.misses += 1;
894 Ok(None)
895 }
896
897 /// Insert a freshly created instance, evicting the least-recently-used
898 /// entry first if this insertion would exceed `max_size`.
899 /// `max_size == 0` means "cache nothing" -- the entry is not inserted
900 /// (and, since there is nothing to make room for, nothing is evicted
901 /// either).
902 fn insert(
903 &mut self,
904 name: String,
905 plugin: Box<dyn OptimizerPlugin<f64>>,
906 config: OptimizerConfig,
907 max_size: usize,
908 ) {
909 if max_size == 0 {
910 return;
911 }
912 if !self.instances.contains_key(&name) && self.instances.len() >= max_size {
913 self.evict_lru();
914 }
915 self.next_sequence += 1;
916 let now = std::time::SystemTime::now();
917 self.instances.insert(
918 name,
919 CachedPlugin {
920 plugin,
921 config,
922 cached_at: now,
923 access_count: 1,
924 last_accessed: now,
925 sequence: self.next_sequence,
926 },
927 );
928 self.recompute_memory_used();
929 }
930
931 /// Evict the entry with the smallest `sequence` (the one least
932 /// recently inserted or hit). A no-op on an empty cache.
933 fn evict_lru(&mut self) {
934 let lru_name = self
935 .instances
936 .iter()
937 .min_by_key(|(_, cached)| cached.sequence)
938 .map(|(name, _)| name.clone());
939 if let Some(lru_name) = lru_name {
940 self.instances.remove(&lru_name);
941 self.stats.evictions += 1;
942 self.recompute_memory_used();
943 }
944 }
945
946 /// Recompute `stats.memory_used` from the currently cached instances;
947 /// see [`CacheStats::memory_used`] for exactly what this does and does
948 /// not account for.
949 fn recompute_memory_used(&mut self) {
950 self.stats.memory_used = self
951 .instances
952 .values()
953 .map(|cached| std::mem::size_of_val(&*cached.plugin))
954 .sum();
955 }
956
957 /// Number of distinct plugin names currently cached. Exposed (crate-
958 /// visible only) for tests asserting eviction actually bounds cache
959 /// size rather than merely incrementing a counter.
960 #[cfg(test)]
961 fn len(&self) -> usize {
962 self.instances.len()
963 }
964}
965
966impl Default for RegistryConfig {
967 fn default() -> Self {
968 Self {
969 auto_discovery: true,
970 validate_on_registration: true,
971 enable_caching: true,
972 max_cache_size: 100,
973 load_timeout: std::time::Duration::from_secs(30),
974 enable_sandboxing: false,
975 allowed_sources: vec![
976 PluginSource::BuiltIn,
977 PluginSource::Local(PathBuf::from("./plugins")),
978 ],
979 }
980 }
981}
982
983// Helper macro for registering plugins
984#[macro_export]
985macro_rules! register_optimizer_plugin {
986 ($factory:expr) => {
987 $crate::plugin::PluginRegistry::global().register_plugin($factory)?
988 };
989}
990
991// Builder pattern for plugin queries
992pub struct PluginQueryBuilder {
993 query: PluginQuery,
994}
995
996impl Default for PluginQueryBuilder {
997 fn default() -> Self {
998 Self::new()
999 }
1000}
1001
1002impl PluginQueryBuilder {
1003 pub fn new() -> Self {
1004 Self {
1005 query: PluginQuery::default(),
1006 }
1007 }
1008
1009 pub fn name_pattern(mut self, pattern: &str) -> Self {
1010 self.query.name_pattern = Some(pattern.to_string());
1011 self
1012 }
1013
1014 pub fn category(mut self, category: PluginCategory) -> Self {
1015 self.query.category = Some(category);
1016 self
1017 }
1018
1019 pub fn data_type(mut self, datatype: DataType) -> Self {
1020 self.query.data_types.push(datatype);
1021 self
1022 }
1023
1024 pub fn tag(mut self, tag: &str) -> Self {
1025 self.query.tags.push(tag.to_string());
1026 self
1027 }
1028
1029 pub fn limit(mut self, limit: usize) -> Self {
1030 self.query.limit = Some(limit);
1031 self
1032 }
1033
1034 pub fn build(self) -> PluginQuery {
1035 self.query
1036 }
1037}
1038
1039#[cfg(test)]
1040mod tests {
1041 use super::*;
1042
1043 #[test]
1044 fn test_plugin_registry_creation() {
1045 let config = RegistryConfig::default();
1046 let registry = PluginRegistry::new(config);
1047 assert_eq!(registry.list_plugins().len(), 0);
1048 }
1049
1050 #[test]
1051 fn test_plugin_query_builder() {
1052 let query = PluginQueryBuilder::new()
1053 .name_pattern("adam")
1054 .category(PluginCategory::FirstOrder)
1055 .data_type(DataType::F32)
1056 .limit(10)
1057 .build();
1058
1059 assert_eq!(query.name_pattern, Some("adam".to_string()));
1060 assert_eq!(query.category, Some(PluginCategory::FirstOrder));
1061 assert_eq!(query.limit, Some(10));
1062 }
1063
1064 #[test]
1065 fn discover_plugins_counts_real_files_on_disk() {
1066 // F69 regression: `discover_plugins_in_directory` previously
1067 // returned a hardcoded `Ok(0)` regardless of directory contents,
1068 // making a directory full of plugin files indistinguishable from
1069 // an empty one.
1070 let root = std::env::temp_dir().join(format!(
1071 "optirs_registry_discover_{}_{}",
1072 std::process::id(),
1073 std::time::SystemTime::now()
1074 .duration_since(std::time::UNIX_EPOCH)
1075 .map(|d| d.as_nanos())
1076 .unwrap_or(0)
1077 ));
1078 let nested = root.join("nested");
1079 std::fs::create_dir_all(&nested).expect("create temp dir tree");
1080
1081 std::fs::write(root.join("plugin.toml"), "[plugin]\nname = \"x\"").expect("write");
1082 std::fs::write(root.join("libfoo.so"), b"not a real library").expect("write");
1083 std::fs::write(root.join("readme.txt"), b"not a plugin").expect("write");
1084 std::fs::write(nested.join("bar.dylib"), b"not a real library").expect("write");
1085
1086 let config = RegistryConfig {
1087 auto_discovery: true,
1088 ..RegistryConfig::default()
1089 };
1090 let registry = PluginRegistry::new(config);
1091 registry.add_search_path(&root);
1092
1093 let discovered = registry
1094 .discover_plugins()
1095 .expect("discovery should succeed");
1096 assert_eq!(
1097 discovered, 3,
1098 "expected plugin.toml + libfoo.so + nested/bar.dylib, not readme.txt"
1099 );
1100
1101 let _ = std::fs::remove_dir_all(&root);
1102 }
1103
1104 #[test]
1105 fn discover_plugins_is_a_noop_when_auto_discovery_disabled() {
1106 let root = std::env::temp_dir().join(format!(
1107 "optirs_registry_discover_disabled_{}_{}",
1108 std::process::id(),
1109 std::time::SystemTime::now()
1110 .duration_since(std::time::UNIX_EPOCH)
1111 .map(|d| d.as_nanos())
1112 .unwrap_or(0)
1113 ));
1114 std::fs::create_dir_all(&root).expect("create temp dir");
1115 std::fs::write(root.join("plugin.toml"), "[plugin]\nname = \"x\"").expect("write");
1116
1117 let config = RegistryConfig {
1118 auto_discovery: false,
1119 ..RegistryConfig::default()
1120 };
1121 let registry = PluginRegistry::new(config);
1122 registry.add_search_path(&root);
1123
1124 assert_eq!(registry.discover_plugins().expect("should succeed"), 0);
1125
1126 let _ = std::fs::remove_dir_all(&root);
1127 }
1128}
1129
1130// Regression tests for the static plugin path: register -> create -> step,
1131// the panic-at-the-trust-boundary guard (a plugin panic must surface as an
1132// error, never poison the process-wide registry), and the lock-poisoning
1133// recovery helpers themselves.
1134#[cfg(test)]
1135mod regression_tests;