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