Skip to main content

cordis_loader/
loader.rs

1//! The loader: entry tree ⇄ fiber lifecycle, file reloads, write-back.
2
3use crate::error::{LoaderError, Result};
4use crate::lock;
5use crate::registry::{PluginRegistry, WithInject};
6use cordis::{Config, Context, EffectHandle, EventOptions, Fiber, FiberState, PluginHandle};
7use cordis_include::{Entry, EntryOptions, EntryTree, LoaderFile, Node, PluginResolver, TreeDiff};
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::{Arc, Mutex, Weak};
11
12/// Where the loader reads and writes its entry file.
13#[derive(Clone, Default)]
14pub struct LoaderConfig {
15    /// Path to the entry config file (`.yml`/`.yaml`/`.json`).
16    pub filename: PathBuf,
17    /// Document written on first run when the file does not exist yet.
18    pub initial: Option<cordis_include::Document>,
19    /// Plugin registry used to resolve entry names; defaults to a fresh
20    /// [`PluginRegistry`] with only the `group` builtin.
21    pub registry: Option<PluginRegistry>,
22}
23
24impl LoaderConfig {
25    /// Configure a loader around `filename`.
26    pub fn new(filename: impl Into<PathBuf>) -> Self {
27        Self {
28            filename: filename.into(),
29            initial: None,
30            registry: None,
31        }
32    }
33
34    /// Provide the document written when the file is missing.
35    pub fn with_initial(mut self, initial: cordis_include::Document) -> Self {
36        self.initial = Some(initial);
37        self
38    }
39
40    /// Provide the plugin registry entries resolve against.
41    pub fn with_registry(mut self, registry: PluginRegistry) -> Self {
42        self.registry = Some(registry);
43        self
44    }
45}
46
47/// Bookkeeping guarded by the loader's state lock.
48struct LoaderState {
49    /// fiber uid -> entry, for status-event routing and lookups.
50    entries: HashMap<u64, Entry>,
51    /// Non-zero while the loader itself drives fibers; self-kill detection
52    /// ignores fibers disposed in that window.
53    operating: u16,
54    /// Last background error (reload callback, self-kill persistence).
55    last_error: Option<String>,
56    /// Keeps the internal listeners and the `loader` service registered.
57    _keep_alive: Vec<EffectHandle>,
58}
59
60/// Cheap cloneable loader handle.
61#[derive(Clone)]
62pub struct Loader {
63    pub(crate) inner: Arc<LoaderInner>,
64}
65
66pub(crate) struct LoaderInner {
67    root: Context,
68    file: LoaderFile,
69    tree: EntryTree,
70    registry: Mutex<PluginRegistry>,
71    state: Mutex<LoaderState>,
72}
73
74/// Weak service handle injected as `loader`, avoiding a reference cycle
75/// between the root context and the loader.
76///
77/// Recover the loader with [`LoaderHandle::upgrade`].
78pub struct LoaderHandle {
79    inner: Weak<LoaderInner>,
80}
81
82impl LoaderHandle {
83    /// Upgrade to a strong loader reference, if still alive.
84    pub fn upgrade(&self) -> Option<Loader> {
85        self.inner.upgrade().map(|inner| Loader { inner })
86    }
87}
88
89impl Loader {
90    /// Open (creating if needed) the entry file, load the tree, and start
91    /// every enabled entry.
92    ///
93    /// Entries that fail to resolve or start do not abort the open; the
94    /// error is recorded and retrievable via [`Loader::last_error`], and the
95    /// offending entry simply has no (or a failed) fiber.
96    pub fn open(root: &Context, config: LoaderConfig) -> Result<Loader> {
97        let file = LoaderFile::open(&config.filename)?;
98        if !file.path().exists() {
99            if let Some(initial) = &config.initial {
100                file.write(initial)?;
101            }
102        }
103        let tree = EntryTree::new();
104        tree.update(file.read()?.entries)?;
105        let inner = Arc::new(LoaderInner {
106            root: root.clone(),
107            file,
108            tree,
109            registry: Mutex::new(config.registry.unwrap_or_default()),
110            state: Mutex::new(LoaderState {
111                entries: HashMap::new(),
112                operating: 0,
113                last_error: None,
114                _keep_alive: Vec::new(),
115            }),
116        });
117
118        // The status listener routes plugin-initiated disposals (self-kill)
119        // back into the config file as `disabled: true`.
120        let weak = Arc::downgrade(&inner);
121        let status = root.events().on(
122            "internal/status",
123            move |event| {
124                if let Some(inner) = weak.upgrade() {
125                    handle_status(&inner, &event)?;
126                }
127                Ok(None)
128            },
129            EventOptions {
130                global: true,
131                ..EventOptions::default()
132            },
133        )?;
134        let service = root.provide_arc(
135            "loader",
136            Arc::new(LoaderHandle {
137                inner: Arc::downgrade(&inner),
138            }),
139        )?;
140        lock(&inner.state)._keep_alive = vec![status, service];
141
142        let loader = Loader { inner };
143        loader.start_all();
144        Ok(loader)
145    }
146
147    /// The root context the loader operates on.
148    pub fn context(&self) -> &Context {
149        &self.inner.root
150    }
151
152    /// The entry tree.
153    pub fn tree(&self) -> &EntryTree {
154        &self.inner.tree
155    }
156
157    /// The entry config file.
158    pub fn file(&self) -> &LoaderFile {
159        &self.inner.file
160    }
161
162    /// The plugin registry (a clone of the current state); populate it via
163    /// [`LoaderConfig::with_registry`] before open, or
164    /// [`Loader::register_plugin`] later.
165    pub fn registry(&self) -> PluginRegistry {
166        lock(&self.inner.registry).clone()
167    }
168
169    /// Register one plugin instance by its own name; picked up by the next
170    /// reload (or immediately for not-yet-started entries).
171    pub fn register_plugin<P: cordis::Plugin>(&self, plugin: P) {
172        lock(&self.inner.registry).register_plugin(plugin);
173    }
174
175    /// Register a handle factory under a name.
176    pub fn register<F>(&self, name: impl Into<String>, factory: F)
177    where
178        F: Fn() -> PluginHandle + Send + Sync + 'static,
179    {
180        lock(&self.inner.registry).register(name, factory);
181    }
182
183    /// The last background error recorded by the loader, if any.
184    pub fn last_error(&self) -> Option<String> {
185        lock(&self.inner.state).last_error.clone()
186    }
187
188    /// The entry whose fiber is `fiber`, if the loader started it.
189    pub fn locate(&self, fiber: &Fiber) -> Option<Entry> {
190        let state = lock(&self.inner.state);
191        if let Some(uid) = fiber.uid() {
192            return state.entries.get(&uid).cloned();
193        }
194        state
195            .entries
196            .values()
197            .find(|entry| entry.fiber().is_some_and(|started| started.ptr_eq(fiber)))
198            .cloned()
199    }
200
201    /// Start every enabled, unstarted entry, parents before children.
202    fn start_all(&self) {
203        for entry in self.inner.tree.entries() {
204            if let Err(error) = start_entry(&self.inner, &entry) {
205                self.record_error(error);
206            }
207        }
208    }
209
210    /// Re-read the entry file and apply the difference to the fibers.
211    ///
212    /// Created entries start (parents first), removed subtrees stop, moved
213    /// entries restart under their new parent, updated entries are patched
214    /// in place when only config changed and restarted otherwise. While the
215    /// reload runs, the file is suspended, so the patches it causes are not
216    /// written back; generated ids are persisted afterwards.
217    pub fn reload(&self) -> Result<TreeDiff> {
218        let inner = &self.inner;
219        let _suspend = inner.file.suspend();
220        let document = inner.file.read()?;
221        let diff = inner.tree.update(document.entries)?;
222
223        for entry in &diff.removed {
224            if let Err(error) = stop_entry(inner, entry) {
225                self.record_error(error);
226            }
227        }
228        for entry in &diff.moved {
229            if let Err(error) = stop_entry(inner, entry) {
230                self.record_error(error);
231            }
232        }
233        for entry in &diff.updated {
234            if let Err(error) = patch_entry(inner, entry) {
235                self.record_error(error);
236            }
237        }
238        for entry in &diff.created {
239            if let Err(error) = start_entry(inner, entry) {
240                self.record_error(error);
241            }
242        }
243        drop(_suspend);
244
245        // Entries created without explicit ids had one generated; persist
246        // it so the next reload can match them.
247        if !diff.created.is_empty() {
248            write_back(inner)?;
249        }
250        Ok(diff)
251    }
252
253    /// Change one entry's config at runtime: the fiber is updated (and
254    /// restarted when active) and the new config is persisted to the file.
255    pub fn update_config(&self, id: &str, config: Node) -> Result<()> {
256        let inner = &self.inner;
257        let entry = inner.tree.resolve(id).ok_or_else(|| {
258            LoaderError::Include(cordis_include::IncludeError::EntryNotFound { id: id.to_owned() })
259        })?;
260        if let Some(fiber) = entry.fiber() {
261            fiber.update_value(Config::new(config.clone()))?;
262        }
263        let mut options = entry_options_with_children(&entry);
264        options.config = Some(config);
265        inner
266            .tree
267            .update_entry(&entry.path(), options, None, None)?;
268        write_back(inner)
269    }
270
271    /// Stop every entry and clear the fiber map. The root context itself
272    /// stays usable.
273    pub fn dispose(&self) -> Result<()> {
274        let inner = &self.inner;
275        for entry in inner.tree.top_level() {
276            if let Err(error) = stop_entry(inner, &entry) {
277                self.record_error(error);
278            }
279        }
280        Ok(())
281    }
282
283    /// Watch the entry file for external changes and reload on them
284    /// (`watch` feature). Reload errors are recorded in
285    /// [`Loader::last_error`].
286    #[cfg(feature = "watch")]
287    pub fn watch(&self) -> Result<cordis_include::FileWatcher> {
288        let loader = self.clone();
289        self.inner
290            .file
291            .watch(move || {
292                if let Err(error) = loader.reload() {
293                    loader.record_error(error);
294                }
295            })
296            .map_err(LoaderError::Include)
297    }
298
299    fn record_error(&self, error: LoaderError) {
300        lock(&self.inner.state).last_error = Some(error.to_string());
301    }
302}
303
304/// Increment `operating` for the lifetime of the guard, so disposals driven
305/// by the loader itself are not mistaken for self-kill.
306struct OperatingGuard<'a> {
307    state: &'a Mutex<LoaderState>,
308}
309
310impl<'a> OperatingGuard<'a> {
311    fn new(state: &'a Mutex<LoaderState>) -> Self {
312        lock(state).operating += 1;
313        Self { state }
314    }
315}
316
317impl Drop for OperatingGuard<'_> {
318    fn drop(&mut self) {
319        let mut state = lock(self.state);
320        state.operating = state.operating.saturating_sub(1);
321    }
322}
323
324/// Start one entry's fiber beneath its parent group's context.
325fn start_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
326    if !entry.enabled() || entry.fiber().is_some() {
327        return Ok(());
328    }
329    let name = entry.name();
330    let handle: PluginHandle = lock(&inner.registry)
331        .resolve(&name)
332        .map_err(LoaderError::Cordis)?;
333    let inject = entry.options().inject;
334    let handle = WithInject::wrap(handle, inject);
335    let config = entry.resolved_config()?.unwrap_or(Node::Null);
336    let parent_ctx = entry
337        .parent()
338        .and_then(|parent| parent.fiber())
339        .and_then(|fiber| fiber.context())
340        .unwrap_or_else(|| inner.root.clone());
341    let fiber = parent_ctx.plugin(handle, config);
342    entry.set_fiber(Some(fiber.clone()));
343    if let Some(uid) = fiber.uid() {
344        lock(&inner.state).entries.insert(uid, entry.clone());
345    }
346    Ok(())
347}
348
349/// Stop one entry's fiber (children first for bookkeeping; disposal of a
350/// group cascades regardless).
351fn stop_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
352    for child in entry.children() {
353        stop_entry(inner, &child)?;
354    }
355    let Some(fiber) = entry.fiber() else {
356        return Ok(());
357    };
358    entry.set_fiber(None);
359    if let Some(uid) = fiber.uid() {
360        lock(&inner.state).entries.remove(&uid);
361    }
362    let _guard = OperatingGuard::new(&inner.state);
363    fiber.dispose().map_err(LoaderError::Cordis)
364}
365
366/// Apply an options change to a live entry: restart when the plugin identity
367/// changed (name, inject) or the enabled flag flipped; patch the config in
368/// place otherwise.
369fn patch_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
370    if !entry.enabled() {
371        return stop_entry(inner, entry);
372    }
373    let Some(fiber) = entry.fiber() else {
374        return start_entry(inner, entry);
375    };
376    let options = entry.options();
377    let inject_changed =
378        fiber.inject().names().collect::<Vec<_>>() != options.inject.iter().collect::<Vec<_>>();
379    if fiber.name() != options.name || inject_changed {
380        stop_entry(inner, entry)?;
381        return start_entry(inner, entry);
382    }
383    let new_config = entry.resolved_config()?.unwrap_or(Node::Null);
384    let current = fiber
385        .config()
386        .downcast::<Node>()
387        .ok()
388        .map(|node| (*node).clone());
389    if current.as_ref() != Some(&new_config) {
390        fiber.update_value(Config::new(new_config))?;
391    }
392    Ok(())
393}
394
395/// Serialize an entry together with its live subtree (used by update paths
396/// that must not disturb children).
397fn entry_options_with_children(entry: &Entry) -> EntryOptions {
398    let mut options = entry.options();
399    options.group = entry
400        .children()
401        .iter()
402        .map(entry_options_with_children)
403        .collect();
404    options
405}
406
407/// Persist the current tree, preserving unknown top-level file keys.
408fn write_back(inner: &LoaderInner) -> Result<()> {
409    let mut document = inner.file.read()?;
410    document.entries = inner.tree.serialize();
411    inner.file.write(&document)?;
412    Ok(())
413}
414
415/// Route `internal/status` disposals: a fiber that reached `Disposed`
416/// outside loader operation was killed by its own plugin, so record
417/// `disabled: true` in the tree and persist it.
418fn handle_status(inner: &LoaderInner, event: &cordis::Event) -> cordis::EventResult {
419    let Some(fiber) = event.arg::<Fiber>(0).ok().flatten() else {
420        return Ok(None);
421    };
422    if fiber.state() != FiberState::Disposed {
423        return Ok(None);
424    }
425    if lock(&inner.state).operating > 0 {
426        return Ok(None);
427    }
428    let Some(entry) = lock(&inner.state)
429        .entries
430        .values()
431        .find(|entry| entry.fiber().is_some_and(|started| started.ptr_eq(&fiber)))
432        .cloned()
433    else {
434        return Ok(None);
435    };
436    if let Err(error) = persist_self_dispose(inner, &entry) {
437        lock(&inner.state).last_error = Some(error.to_string());
438    }
439    Ok(None)
440}
441
442/// A plugin disposed itself: unmap the entry and persist `disabled: true`.
443fn persist_self_dispose(inner: &LoaderInner, entry: &Entry) -> Result<()> {
444    {
445        let mut state = lock(&inner.state);
446        let key = state
447            .entries
448            .iter()
449            .find(|(_, mapped)| Entry::ptr_eq(mapped, entry))
450            .map(|(uid, _)| *uid);
451        if let Some(uid) = key {
452            state.entries.remove(&uid);
453        }
454    }
455    entry.set_fiber(None);
456    let mut options = entry_options_with_children(entry);
457    options.disabled = true;
458    inner
459        .tree
460        .update_entry(&entry.path(), options, None, None)?;
461    write_back(inner)
462}