Skip to main content

cordis/
registry.rs

1//! Plugin entrypoints, dependency declarations, and runtime registry.
2
3use crate::context::Context;
4use crate::effect::AsyncDisposer;
5use crate::fiber::{Fiber, FiberInner};
6use crate::utils::{BoxFuture, lock};
7use crate::{Config, CordisError, ErrorCode, Result, Value};
8use std::fmt::{self, Debug, Formatter};
9use std::future::Future;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Arc, Mutex, Weak};
12
13/// One required service and its optional intercept configuration.
14#[derive(Debug, Clone)]
15pub struct Dependency {
16    /// Required service name.
17    pub name: String,
18    /// Config appended to that service's intercept chain in the plugin context.
19    pub config: Option<Value>,
20}
21
22/// Normalized plugin service dependencies.
23///
24/// Every entry must be satisfiable by a service registered with
25/// `provide`/`provide_arc`. Names declared only through
26/// [`accessor()`](crate::Context::accessor) never satisfy a dependency —
27/// matching upstream, which only consults the provide store — so depending
28/// on one keeps the fiber `Pending` forever.
29#[derive(Debug, Clone, Default)]
30pub struct Inject {
31    entries: Vec<Dependency>,
32}
33
34impl Inject {
35    /// Construct an inject declaration from service names.
36    pub fn new<I, S>(names: I) -> Self
37    where
38        I: IntoIterator<Item = S>,
39        S: Into<String>,
40    {
41        Self {
42            entries: names
43                .into_iter()
44                .map(|name| Dependency {
45                    name: name.into(),
46                    config: None,
47                })
48                .collect(),
49        }
50    }
51
52    /// Construct an empty declaration.
53    pub fn none() -> Self {
54        Self::default()
55    }
56
57    /// Add a required service without intercept config.
58    pub fn require(mut self, name: impl Into<String>) -> Self {
59        self.entries.push(Dependency {
60            name: name.into(),
61            config: None,
62        });
63        self
64    }
65
66    /// Add a required service and intercept config.
67    pub fn require_with<T>(mut self, name: impl Into<String>, config: T) -> Self
68    where
69        T: Send + Sync + 'static,
70    {
71        self.entries.push(Dependency {
72            name: name.into(),
73            config: Some(Value::new(config)),
74        });
75        self
76    }
77
78    /// Add a type-erased intercept config.
79    pub fn require_with_value(mut self, name: impl Into<String>, config: Value) -> Self {
80        self.entries.push(Dependency {
81            name: name.into(),
82            config: Some(config),
83        });
84        self
85    }
86
87    /// Iterate dependencies in declaration order.
88    pub fn iter(&self) -> impl Iterator<Item = &Dependency> {
89        self.entries.iter()
90    }
91
92    /// Number of dependencies.
93    pub fn len(&self) -> usize {
94        self.entries.len()
95    }
96
97    /// Whether no services are required.
98    pub fn is_empty(&self) -> bool {
99        self.entries.is_empty()
100    }
101
102    /// Whether this declaration contains `name`.
103    pub fn contains(&self, name: &str) -> bool {
104        self.entries.iter().any(|entry| entry.name == name)
105    }
106
107    /// Return just the service names.
108    pub fn names(&self) -> impl Iterator<Item = &str> {
109        self.entries.iter().map(|entry| entry.name.as_str())
110    }
111}
112
113impl<const N: usize> From<[&str; N]> for Inject {
114    fn from(value: [&str; N]) -> Self {
115        Self::new(value)
116    }
117}
118
119impl From<Vec<String>> for Inject {
120    fn from(value: Vec<String>) -> Self {
121        Self::new(value)
122    }
123}
124
125/// Resources returned by plugin startup and owned by its fiber.
126#[derive(Debug, Default)]
127pub struct PluginOutput {
128    pub(crate) disposers: Vec<(String, AsyncDisposer)>,
129}
130
131impl PluginOutput {
132    /// Return no additional cleanup. Effects registered through `ctx` are
133    /// still owned by the plugin fiber.
134    pub fn none() -> Self {
135        Self::default()
136    }
137
138    /// Return one synchronous cleanup operation.
139    pub fn disposer<F>(dispose: F) -> Self
140    where
141        F: FnOnce() -> Result<()> + Send + 'static,
142    {
143        Self::default().with_disposer("plugin return", dispose)
144    }
145
146    /// Return one infallible synchronous cleanup operation.
147    pub fn infallible<F>(dispose: F) -> Self
148    where
149        F: FnOnce() + Send + 'static,
150    {
151        let mut output = Self::default();
152        output.disposers.push((
153            "plugin return".to_owned(),
154            AsyncDisposer::infallible(dispose),
155        ));
156        output
157    }
158
159    /// Append a named synchronous cleanup operation.
160    pub fn with_disposer<F>(mut self, label: impl Into<String>, dispose: F) -> Self
161    where
162        F: FnOnce() -> Result<()> + Send + 'static,
163    {
164        self.disposers
165            .push((label.into(), AsyncDisposer::from_sync(dispose)));
166        self
167    }
168
169    /// Append an already boxed asynchronous disposer.
170    pub fn with_async_disposer(mut self, label: impl Into<String>, dispose: AsyncDisposer) -> Self {
171        self.disposers.push((label.into(), dispose));
172        self
173    }
174}
175
176/// Object-safe Cordis plugin entrypoint.
177///
178/// Implementations receive type-erased config to support heterogeneous plugin
179/// registries. [`plugin_sync`] and [`plugin_async`] provide typed adapters for
180/// ordinary closures.
181pub trait Plugin: Send + Sync + 'static {
182    /// Display name used by fibers and loggers.
183    fn name(&self) -> &str;
184
185    /// Required services. Startup waits in `Pending` until all are active.
186    fn inject(&self) -> &Inject {
187        static EMPTY: Inject = Inject {
188            entries: Vec::new(),
189        };
190        &EMPTY
191    }
192
193    /// Validate and optionally normalize raw config before startup.
194    fn validate_config(&self, config: Config) -> Result<Config> {
195        Ok(config)
196    }
197
198    /// Start this plugin in `ctx`.
199    fn apply(&self, ctx: Context, config: Config) -> BoxFuture<Result<PluginOutput>>;
200}
201
202/// Stable identity shared by every fiber started from one [`PluginHandle`].
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
204pub struct PluginKey(pub u64);
205
206static NEXT_PLUGIN: AtomicU64 = AtomicU64::new(0);
207
208/// Cloneable, dynamically dispatched plugin with stable registry identity.
209#[derive(Clone)]
210pub struct PluginHandle {
211    key: PluginKey,
212    plugin: Arc<dyn Plugin>,
213}
214
215impl PluginHandle {
216    /// Wrap a plugin implementation.
217    pub fn new<P: Plugin>(plugin: P) -> Self {
218        Self {
219            key: PluginKey(NEXT_PLUGIN.fetch_add(1, Ordering::Relaxed) + 1),
220            plugin: Arc::new(plugin),
221        }
222    }
223
224    /// Return this callback's stable identity.
225    pub const fn key(&self) -> PluginKey {
226        self.key
227    }
228
229    /// Return the plugin display name.
230    pub fn name(&self) -> &str {
231        self.plugin.name()
232    }
233
234    /// Return the wrapped dynamically dispatched plugin.
235    ///
236    /// Intended for delegation wrappers (adding inject declarations,
237    /// intercepting validate/apply) that forward to the inner plugin while
238    /// keeping their own [`PluginHandle`] identity.
239    pub fn plugin(&self) -> &Arc<dyn Plugin> {
240        &self.plugin
241    }
242}
243
244impl Debug for PluginHandle {
245    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
246        f.debug_struct("PluginHandle")
247            .field("key", &self.key)
248            .field("name", &self.name())
249            .finish()
250    }
251}
252
253/// Conversion accepted by [`Context::plugin`](crate::Context::plugin).
254pub trait IntoPlugin {
255    /// Produce a stable plugin handle.
256    fn into_plugin(self) -> PluginHandle;
257}
258
259impl IntoPlugin for PluginHandle {
260    fn into_plugin(self) -> PluginHandle {
261        self
262    }
263}
264
265struct FunctionPlugin {
266    name: String,
267    inject: Inject,
268    callback: Arc<dyn Fn(Context, Config) -> BoxFuture<Result<PluginOutput>> + Send + Sync>,
269}
270
271impl Plugin for FunctionPlugin {
272    fn name(&self) -> &str {
273        &self.name
274    }
275
276    fn inject(&self) -> &Inject {
277        &self.inject
278    }
279
280    fn apply(&self, ctx: Context, config: Config) -> BoxFuture<Result<PluginOutput>> {
281        (self.callback)(ctx, config)
282    }
283}
284
285/// Adapt a typed synchronous closure to a Cordis plugin.
286pub fn plugin_sync<C, F>(name: impl Into<String>, inject: Inject, callback: F) -> PluginHandle
287where
288    C: Send + Sync + 'static,
289    F: Fn(Context, Arc<C>) -> Result<PluginOutput> + Send + Sync + 'static,
290{
291    let callback = Arc::new(callback);
292    PluginHandle::new(FunctionPlugin {
293        name: name.into(),
294        inject,
295        callback: Arc::new(move |ctx, config| {
296            let callback = callback.clone();
297            Box::pin(async move {
298                let config = config.downcast::<C>().map_err(|error| {
299                    CordisError::with_message(
300                        ErrorCode::InvalidConfig,
301                        format!("invalid config type: {error}"),
302                    )
303                })?;
304                callback(ctx, config)
305            })
306        }),
307    })
308}
309
310/// Adapt a typed asynchronous closure to a Cordis plugin.
311///
312/// The future is driven by a small blocking executor while the fiber's
313/// transition mutex is held. Do not await futures that require the current
314/// thread to make progress (for example `tokio::task::spawn_blocking` joins
315/// or channels filled by the calling thread); park only on work completing
316/// on other threads.
317pub fn plugin_async<C, F, Fut>(name: impl Into<String>, inject: Inject, callback: F) -> PluginHandle
318where
319    C: Send + Sync + 'static,
320    F: Fn(Context, Arc<C>) -> Fut + Send + Sync + 'static,
321    Fut: Future<Output = Result<PluginOutput>> + Send + 'static,
322{
323    let callback = Arc::new(callback);
324    PluginHandle::new(FunctionPlugin {
325        name: name.into(),
326        inject,
327        callback: Arc::new(move |ctx, config| {
328            let callback = callback.clone();
329            Box::pin(async move {
330                let config = config.downcast::<C>().map_err(|error| {
331                    CordisError::with_message(
332                        ErrorCode::InvalidConfig,
333                        format!("invalid config type: {error}"),
334                    )
335                })?;
336                callback(ctx, config).await
337            })
338        }),
339    })
340}
341
342pub(crate) struct RuntimeRecord {
343    pub(crate) handle: PluginHandle,
344    pub(crate) fibers: Vec<Weak<FiberInner>>,
345}
346
347#[derive(Default)]
348pub(crate) struct RegistryState {
349    pub(crate) runtimes: std::collections::BTreeMap<PluginKey, RuntimeRecord>,
350    /// Service name → fibers injecting it, so service notifications scan only
351    /// interested fibers instead of upgrading the whole registry.
352    pub(crate) injectors: std::collections::HashMap<String, Vec<Weak<FiberInner>>>,
353}
354
355pub(crate) struct RegistryRoot {
356    pub(crate) state: Mutex<RegistryState>,
357}
358
359impl RegistryRoot {
360    pub(crate) fn new() -> Self {
361        Self {
362            state: Mutex::new(RegistryState::default()),
363        }
364    }
365
366    pub(crate) fn remove_fiber(&self, key: PluginKey, uid: u64) {
367        let mut state = lock(&self.state);
368        let remove_runtime = if let Some(runtime) = state.runtimes.get_mut(&key) {
369            runtime.fibers.retain(|weak| {
370                weak.upgrade()
371                    .and_then(|fiber| fiber.uid_value())
372                    .map(|fiber_uid| fiber_uid != uid)
373                    .unwrap_or(false)
374            });
375            runtime.fibers.is_empty()
376        } else {
377            false
378        };
379        if remove_runtime {
380            state.runtimes.remove(&key);
381        }
382        // The uid is cleared before removal, so the target matches via the
383        // same None-collapse as above; dead weaks are pruned along the way.
384        for weaks in state.injectors.values_mut() {
385            weaks.retain(|weak| {
386                weak.upgrade()
387                    .and_then(|fiber| fiber.uid_value())
388                    .map(|fiber_uid| fiber_uid != uid)
389                    .unwrap_or(false)
390            });
391        }
392        state.injectors.retain(|_, weaks| !weaks.is_empty());
393    }
394
395    /// Live fibers injecting `name`; prunes dead weak references as a side
396    /// effect.
397    pub(crate) fn fibers_injecting(&self, name: &str) -> Vec<Fiber> {
398        let mut state = lock(&self.state);
399        let Some(weaks) = state.injectors.get_mut(name) else {
400            return Vec::new();
401        };
402        let mut fibers = Vec::with_capacity(weaks.len());
403        weaks.retain(|weak| {
404            if let Some(fiber) = weak.upgrade() {
405                fibers.push(Fiber::from_inner(fiber));
406                true
407            } else {
408                false
409            }
410        });
411        if weaks.is_empty() {
412            state.injectors.remove(name);
413        }
414        fibers
415    }
416}
417
418/// Read-only snapshot of one plugin runtime.
419#[derive(Debug, Clone)]
420pub struct RuntimeInfo {
421    /// Stable plugin identity.
422    pub key: PluginKey,
423    /// Display name.
424    pub name: String,
425    /// Live fibers for this callback.
426    pub fibers: Vec<Fiber>,
427}
428
429/// Plugin registry bound to a context.
430#[derive(Clone, Debug)]
431pub struct RegistryService {
432    ctx: Context,
433}
434
435impl RegistryService {
436    pub(crate) fn new(ctx: Context) -> Self {
437        Self { ctx }
438    }
439
440    /// Number of registered plugin callbacks.
441    pub fn len(&self) -> usize {
442        lock(&self.ctx.root.registry.state).runtimes.len()
443    }
444
445    /// Whether no plugins are registered.
446    pub fn is_empty(&self) -> bool {
447        self.len() == 0
448    }
449
450    /// Whether a plugin handle has at least one live fiber.
451    ///
452    /// Dead weak references are pruned as a side effect, so a runtime whose
453    /// fibers were all dropped without `dispose()` stops being reported here.
454    pub fn contains(&self, plugin: &PluginHandle) -> bool {
455        let mut state = lock(&self.ctx.root.registry.state);
456        let Some(runtime) = state.runtimes.get_mut(&plugin.key()) else {
457            return false;
458        };
459        runtime
460            .fibers
461            .retain(|weak| weak.upgrade().and_then(|fiber| fiber.uid_value()).is_some());
462        if runtime.fibers.is_empty() {
463            state.runtimes.remove(&plugin.key());
464            false
465        } else {
466            true
467        }
468    }
469
470    /// Return runtime snapshots.
471    pub fn values(&self) -> Vec<RuntimeInfo> {
472        let mut state = lock(&self.ctx.root.registry.state);
473        state
474            .runtimes
475            .iter_mut()
476            .map(|(key, runtime)| {
477                let mut fibers = Vec::new();
478                runtime.fibers.retain(|fiber| {
479                    if let Some(fiber) = fiber.upgrade() {
480                        fibers.push(Fiber::from_inner(fiber));
481                        true
482                    } else {
483                        false
484                    }
485                });
486                RuntimeInfo {
487                    key: *key,
488                    name: runtime.handle.name().to_owned(),
489                    fibers,
490                }
491            })
492            .collect()
493    }
494
495    /// Start a plugin from type-erased config.
496    pub fn plugin_value(&self, plugin: PluginHandle, config: Config) -> Fiber {
497        let fiber = Fiber::new_plugin(&self.ctx, plugin.clone(), config);
498        let weak = Arc::downgrade(&fiber.inner);
499        {
500            let mut state = lock(&self.ctx.root.registry.state);
501            state
502                .runtimes
503                .entry(plugin.key())
504                .or_insert_with(|| RuntimeRecord {
505                    handle: plugin.clone(),
506                    fibers: Vec::new(),
507                })
508                .fibers
509                .push(weak.clone());
510            for dependency in fiber.inject().iter() {
511                state
512                    .injectors
513                    .entry(dependency.name.clone())
514                    .or_default()
515                    .push(weak.clone());
516            }
517        }
518
519        // Parent ownership mirrors the `ctx.plugin()` structural effect in
520        // TypeScript. The registry itself only keeps weak fiber references.
521        let owned = fiber.clone();
522        match self.ctx.fiber().and_then(|parent| {
523            parent.register_effect(
524                "ctx.plugin()",
525                AsyncDisposer::from_async(move || async move { owned.dispose_async().await }),
526            )
527        }) {
528            Ok(effect) => fiber.set_parent_effect(effect),
529            Err(error) => fiber.reject(error),
530        }
531
532        if fiber.uid().is_some() {
533            let _ = self
534                .ctx
535                .events()
536                .emit("internal/plugin", [Value::new(fiber.clone())]);
537            fiber.refresh();
538        }
539        fiber
540    }
541
542    /// Dispose all fibers created from `plugin` and remove its runtime.
543    pub fn delete(&self, plugin: &PluginHandle) -> bool {
544        let fibers = {
545            let mut state = lock(&self.ctx.root.registry.state);
546            let Some(runtime) = state.runtimes.remove(&plugin.key()) else {
547                return false;
548            };
549            runtime
550                .fibers
551                .into_iter()
552                .filter_map(|fiber| fiber.upgrade())
553                .map(Fiber::from_inner)
554                .collect::<Vec<_>>()
555        };
556        for fiber in fibers {
557            if let Err(error) = fiber.dispose() {
558                self.ctx.log_error(error);
559            }
560        }
561        true
562    }
563}