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).
254///
255/// Implemented for every [`Plugin`] (wrapping it in a fresh
256/// [`PluginHandle`]) and for [`PluginHandle`] itself (identity).
257pub trait IntoPlugin {
258    /// Produce a stable plugin handle.
259    fn into_plugin(self) -> PluginHandle;
260}
261
262impl IntoPlugin for PluginHandle {
263    fn into_plugin(self) -> PluginHandle {
264        self
265    }
266}
267
268impl<T: Plugin> IntoPlugin for T {
269    fn into_plugin(self) -> PluginHandle {
270        PluginHandle::new(self)
271    }
272}
273
274struct FunctionPlugin {
275    name: String,
276    inject: Inject,
277    validate: fn(Config) -> Result<Config>,
278    callback: Arc<dyn Fn(Context, Config) -> BoxFuture<Result<PluginOutput>> + Send + Sync>,
279}
280
281/// Pre-check that `config` holds a `C`, so a wrong-typed update fails
282/// validation instead of tearing down the running instance first.
283fn config_validator<C>() -> fn(Config) -> Result<Config>
284where
285    C: Send + Sync + 'static,
286{
287    fn validate<C>(config: Config) -> Result<Config>
288    where
289        C: Send + Sync + 'static,
290    {
291        config.downcast::<C>().map(|_| config)
292    }
293    validate::<C>
294}
295
296impl Plugin for FunctionPlugin {
297    fn name(&self) -> &str {
298        &self.name
299    }
300
301    fn inject(&self) -> &Inject {
302        &self.inject
303    }
304
305    fn validate_config(&self, config: Config) -> Result<Config> {
306        (self.validate)(config)
307    }
308
309    fn apply(&self, ctx: Context, config: Config) -> BoxFuture<Result<PluginOutput>> {
310        (self.callback)(ctx, config)
311    }
312}
313
314/// Adapt a typed synchronous closure to a Cordis plugin.
315pub fn plugin_sync<C, F>(name: impl Into<String>, inject: Inject, callback: F) -> PluginHandle
316where
317    C: Send + Sync + 'static,
318    F: Fn(Context, Arc<C>) -> Result<PluginOutput> + Send + Sync + 'static,
319{
320    let callback = Arc::new(callback);
321    PluginHandle::new(FunctionPlugin {
322        name: name.into(),
323        inject,
324        validate: config_validator::<C>(),
325        callback: Arc::new(move |ctx, config| {
326            let callback = callback.clone();
327            Box::pin(async move {
328                let config = config.downcast::<C>().map_err(|error| {
329                    CordisError::with_message(
330                        ErrorCode::InvalidConfig,
331                        format!("invalid config type: {error}"),
332                    )
333                })?;
334                callback(ctx, config)
335            })
336        }),
337    })
338}
339
340/// Adapt a typed asynchronous closure to a Cordis plugin.
341///
342/// The future is driven by a small blocking executor while the fiber's
343/// transition mutex is held. Do not await futures that require the current
344/// thread to make progress (for example `tokio::task::spawn_blocking` joins
345/// or channels filled by the calling thread); park only on work completing
346/// on other threads.
347pub fn plugin_async<C, F, Fut>(name: impl Into<String>, inject: Inject, callback: F) -> PluginHandle
348where
349    C: Send + Sync + 'static,
350    F: Fn(Context, Arc<C>) -> Fut + Send + Sync + 'static,
351    Fut: Future<Output = Result<PluginOutput>> + Send + 'static,
352{
353    let callback = Arc::new(callback);
354    PluginHandle::new(FunctionPlugin {
355        name: name.into(),
356        inject,
357        validate: config_validator::<C>(),
358        callback: Arc::new(move |ctx, config| {
359            let callback = callback.clone();
360            Box::pin(async move {
361                let config = config.downcast::<C>().map_err(|error| {
362                    CordisError::with_message(
363                        ErrorCode::InvalidConfig,
364                        format!("invalid config type: {error}"),
365                    )
366                })?;
367                callback(ctx, config).await
368            })
369        }),
370    })
371}
372
373pub(crate) struct RuntimeRecord {
374    pub(crate) handle: PluginHandle,
375    pub(crate) fibers: Vec<Weak<FiberInner>>,
376}
377
378#[derive(Default)]
379pub(crate) struct RegistryState {
380    pub(crate) runtimes: std::collections::BTreeMap<PluginKey, RuntimeRecord>,
381    /// Service name → fibers injecting it, so service notifications scan only
382    /// interested fibers instead of upgrading the whole registry.
383    pub(crate) injectors: std::collections::HashMap<String, Vec<Weak<FiberInner>>>,
384}
385
386pub(crate) struct RegistryRoot {
387    pub(crate) state: Mutex<RegistryState>,
388}
389
390impl RegistryRoot {
391    pub(crate) fn new() -> Self {
392        Self {
393            state: Mutex::new(RegistryState::default()),
394        }
395    }
396
397    pub(crate) fn remove_fiber(&self, key: PluginKey, uid: u64) {
398        let mut state = lock(&self.state);
399        let remove_runtime = if let Some(runtime) = state.runtimes.get_mut(&key) {
400            runtime.fibers.retain(|weak| {
401                weak.upgrade()
402                    .and_then(|fiber| fiber.uid_value())
403                    .map(|fiber_uid| fiber_uid != uid)
404                    .unwrap_or(false)
405            });
406            runtime.fibers.is_empty()
407        } else {
408            false
409        };
410        if remove_runtime {
411            state.runtimes.remove(&key);
412        }
413        // The uid is cleared before removal, so the target matches via the
414        // same None-collapse as above; dead weaks are pruned along the way.
415        for weaks in state.injectors.values_mut() {
416            weaks.retain(|weak| {
417                weak.upgrade()
418                    .and_then(|fiber| fiber.uid_value())
419                    .map(|fiber_uid| fiber_uid != uid)
420                    .unwrap_or(false)
421            });
422        }
423        state.injectors.retain(|_, weaks| !weaks.is_empty());
424    }
425
426    /// Live fibers injecting `name`; prunes dead weak references as a side
427    /// effect.
428    pub(crate) fn fibers_injecting(&self, name: &str) -> Vec<Fiber> {
429        let mut state = lock(&self.state);
430        let Some(weaks) = state.injectors.get_mut(name) else {
431            return Vec::new();
432        };
433        let mut fibers = Vec::with_capacity(weaks.len());
434        weaks.retain(|weak| {
435            if let Some(fiber) = weak.upgrade() {
436                fibers.push(Fiber::from_inner(fiber));
437                true
438            } else {
439                false
440            }
441        });
442        if weaks.is_empty() {
443            state.injectors.remove(name);
444        }
445        fibers
446    }
447}
448
449/// Drop dead weak fiber references, then the runtimes left without a single
450/// live fiber. Called under the registry state lock by the read APIs
451/// (`len`, `contains`, `values`) so a plugin whose fibers were all dropped
452/// without `dispose()` — documented as legitimate — stops being reported
453/// instead of lingering forever.
454fn prune_runtimes(state: &mut RegistryState) {
455    state.runtimes.retain(|_, runtime| {
456        runtime.fibers.retain(|weak| {
457            weak.upgrade()
458                .is_some_and(|fiber| fiber.uid_value().is_some())
459        });
460        !runtime.fibers.is_empty()
461    });
462}
463
464/// Read-only snapshot of one plugin runtime.
465#[derive(Debug, Clone)]
466pub struct RuntimeInfo {
467    /// Stable plugin identity.
468    pub key: PluginKey,
469    /// Display name.
470    pub name: String,
471    /// Live fibers for this callback.
472    pub fibers: Vec<Fiber>,
473}
474
475/// Plugin registry bound to a context.
476#[derive(Clone, Debug)]
477pub struct RegistryService {
478    ctx: Context,
479}
480
481impl RegistryService {
482    pub(crate) fn new(ctx: Context) -> Self {
483        Self { ctx }
484    }
485
486    /// Number of registered plugin callbacks.
487    ///
488    /// Dead weak references are pruned as a side effect (see
489    /// [`contains`](Self::contains)), so runtimes whose fibers were all
490    /// dropped without `dispose()` stop being counted here.
491    pub fn len(&self) -> usize {
492        let mut state = lock(&self.ctx.root.registry.state);
493        prune_runtimes(&mut state);
494        state.runtimes.len()
495    }
496
497    /// Whether no plugins are registered.
498    pub fn is_empty(&self) -> bool {
499        self.len() == 0
500    }
501
502    /// Whether a plugin handle has at least one live fiber.
503    ///
504    /// Dead weak references are pruned as a side effect, so a runtime whose
505    /// fibers were all dropped without `dispose()` stops being reported here.
506    pub fn contains(&self, plugin: &PluginHandle) -> bool {
507        let mut state = lock(&self.ctx.root.registry.state);
508        prune_runtimes(&mut state);
509        state
510            .runtimes
511            .get(&plugin.key())
512            .is_some_and(|runtime| !runtime.fibers.is_empty())
513    }
514
515    /// Return runtime snapshots.
516    ///
517    /// Like [`contains`](Self::contains), dead weak references are pruned
518    /// and a runtime whose fibers were all dropped without `dispose()` is
519    /// removed, so it disappears from later [`len`](Self::len) counts too.
520    pub fn runtimes(&self) -> Vec<RuntimeInfo> {
521        let mut state = lock(&self.ctx.root.registry.state);
522        prune_runtimes(&mut state);
523        state
524            .runtimes
525            .values()
526            .map(|runtime| RuntimeInfo {
527                key: runtime.handle.key(),
528                name: runtime.handle.name().to_owned(),
529                fibers: runtime
530                    .fibers
531                    .iter()
532                    .filter_map(|weak| weak.upgrade().map(Fiber::from_inner))
533                    .collect(),
534            })
535            .collect()
536    }
537
538    /// Start a plugin from type-erased config.
539    pub fn plugin_value(&self, plugin: PluginHandle, config: Config) -> Fiber {
540        let fiber = Fiber::new_plugin(&self.ctx, plugin.clone(), config);
541        let weak = Arc::downgrade(&fiber.inner);
542        {
543            let mut state = lock(&self.ctx.root.registry.state);
544            state
545                .runtimes
546                .entry(plugin.key())
547                .or_insert_with(|| RuntimeRecord {
548                    handle: plugin.clone(),
549                    fibers: Vec::new(),
550                })
551                .fibers
552                .push(weak.clone());
553            for dependency in fiber.inject().iter() {
554                state
555                    .injectors
556                    .entry(dependency.name.clone())
557                    .or_default()
558                    .push(weak.clone());
559            }
560        }
561
562        // Parent ownership mirrors the `ctx.plugin()` structural effect in
563        // TypeScript. The registry itself only keeps weak fiber references.
564        let owned = fiber.clone();
565        match self.ctx.fiber().and_then(|parent| {
566            parent.register_effect(
567                "ctx.plugin()",
568                AsyncDisposer::from_async(move || async move { owned.dispose_async().await }),
569            )
570        }) {
571            Ok(effect) => fiber.set_parent_effect(effect),
572            Err(error) => fiber.reject(error),
573        }
574
575        if fiber.uid().is_some() {
576            let _ = self
577                .ctx
578                .events()
579                .emit("internal/plugin", [Value::new(fiber.clone())]);
580            fiber.refresh();
581        }
582        fiber
583    }
584
585    /// Dispose all fibers created from `plugin` and remove its runtime.
586    pub fn delete(&self, plugin: &PluginHandle) -> bool {
587        let fibers = {
588            let mut state = lock(&self.ctx.root.registry.state);
589            let Some(runtime) = state.runtimes.remove(&plugin.key()) else {
590                return false;
591            };
592            runtime
593                .fibers
594                .into_iter()
595                .filter_map(|fiber| fiber.upgrade())
596                .map(Fiber::from_inner)
597                .collect::<Vec<_>>()
598        };
599        for fiber in fibers {
600            if let Err(error) = fiber.dispose() {
601                self.ctx.log_error(error);
602            }
603        }
604        true
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use crate::{Context, Inject, PluginOutput};
612
613    /// Regression: values()/len() used to keep a runtime alive forever once
614    /// its fibers were dropped without dispose() (the documented case for
615    /// contains()'s pruning). Fabricating a runtime whose only weak is
616    /// already dead exercises the pruning directly.
617    #[test]
618    fn read_apis_prune_runtimes_with_only_dead_fibers() {
619        let root = Context::new();
620        let handle =
621            crate::plugin_sync::<(), _>(
622                "ghost",
623                Inject::none(),
624                |_, _| Ok(PluginOutput::default()),
625            );
626        {
627            let mut state = lock(&root.root.registry.state);
628            state.runtimes.insert(
629                handle.key(),
630                RuntimeRecord {
631                    handle: handle.clone(),
632                    fibers: vec![Weak::new()],
633                },
634            );
635        }
636        assert_eq!(root.registry().len(), 0, "len prunes empty runtimes");
637        assert!(root.registry().runtimes().is_empty());
638        assert!(!root.registry().contains(&handle));
639    }
640}