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::{
7    Config, Context, CordisError, EffectHandle, ErrorCode, EventOptions, Fiber, FiberState,
8    PluginHandle, Value,
9};
10use cordis_include::{Entry, EntryOptions, EntryTree, LoaderFile, Node, PluginResolver, TreeDiff};
11use std::collections::{HashMap, HashSet};
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Condvar, Mutex, Weak};
14use std::thread::ThreadId;
15use std::time::Duration;
16
17/// Where the loader reads and writes its entry file.
18#[derive(Clone, Default)]
19pub struct LoaderConfig {
20    /// Path to the entry config file (`.yml`/`.yaml`/`.json`).
21    pub filename: PathBuf,
22    /// Document written on first run when the file does not exist yet.
23    pub initial: Option<cordis_include::Document>,
24    /// Plugin registry used to resolve entry names; defaults to a fresh
25    /// [`PluginRegistry`] with only the `group` builtin.
26    pub registry: Option<PluginRegistry>,
27    /// Debounce window for coalesced config writes; `None` (default)
28    /// persists every write synchronously.
29    pub write_debounce: Option<Duration>,
30}
31
32impl LoaderConfig {
33    /// Configure a loader around `filename`.
34    pub fn new(filename: impl Into<PathBuf>) -> Self {
35        Self {
36            filename: filename.into(),
37            initial: None,
38            registry: None,
39            write_debounce: None,
40        }
41    }
42
43    /// Provide the document written when the file is missing.
44    pub fn with_initial(mut self, initial: cordis_include::Document) -> Self {
45        self.initial = Some(initial);
46        self
47    }
48
49    /// Provide the plugin registry entries resolve against.
50    pub fn with_registry(mut self, registry: PluginRegistry) -> Self {
51        self.registry = Some(registry);
52        self
53    }
54
55    /// Coalesce config writes: rapid write-backs merge and land once after
56    /// this much quiet time.
57    pub fn with_write_debounce(mut self, delay: Duration) -> Self {
58        self.write_debounce = Some(delay);
59        self
60    }
61}
62
63/// Bookkeeping guarded by the loader's state lock.
64struct LoaderState {
65    /// fiber uid -> entry, for status-event routing and lookups.
66    entries: HashMap<u64, Entry>,
67    /// Non-zero while the loader itself drives fibers; self-kill detection
68    /// ignores fibers disposed in that window.
69    operating: u16,
70    /// Last background error (reload callback, self-kill persistence).
71    last_error: Option<String>,
72    /// Keeps the internal listeners and the `loader` service registered.
73    _keep_alive: Vec<EffectHandle>,
74}
75
76/// Cheap cloneable loader handle.
77#[derive(Clone)]
78pub struct Loader {
79    pub(crate) inner: Arc<LoaderInner>,
80}
81
82pub(crate) struct LoaderInner {
83    root: Context,
84    file: LoaderFile,
85    tree: EntryTree,
86    registry: Mutex<PluginRegistry>,
87    state: Mutex<LoaderState>,
88    /// Serializes the loader's state transitions end to end — `reload`,
89    /// `update_config`, `dispose`, and deferred self-kill persistence — so
90    /// file reads, tree diffs, fiber patches, and write-backs always run in
91    /// a consistent order. Without it a watch-thread `reload` can interleave
92    /// with a plugin-thread `update_config` and leave the fiber serving a
93    /// different config than the tree and the file claim, with no later
94    /// event to reconcile the difference. Reentrancy-aware because loader
95    /// event listeners legitimately call back into the loader.
96    operation: OperationLock,
97    /// Canonical path -> file of every import currently mounted.
98    imports: Mutex<HashMap<PathBuf, LoaderFile>>,
99    /// Paths already armed by [`Loader::watch`] (watch feature).
100    #[cfg(feature = "watch")]
101    watched: Mutex<HashSet<PathBuf>>,
102    /// Import-file watchers kept alive for hot reload (watch feature).
103    #[cfg(feature = "watch")]
104    watchers: Mutex<Vec<cordis_include::FileWatcher>>,
105    /// Debounce window for coalesced writes; `None` writes synchronously.
106    write_debounce: Mutex<Option<Duration>>,
107}
108
109/// Weak service handle injected as `loader`, avoiding a reference cycle
110/// between the root context and the loader.
111///
112/// Recover the loader with [`LoaderHandle::upgrade`].
113pub struct LoaderHandle {
114    inner: Weak<LoaderInner>,
115}
116
117impl LoaderHandle {
118    /// Upgrade to a strong loader reference, if still alive.
119    pub fn upgrade(&self) -> Option<Loader> {
120        self.inner.upgrade().map(|inner| Loader { inner })
121    }
122}
123
124impl Loader {
125    /// Open (creating if needed) the entry file, load the tree, and start
126    /// every enabled entry.
127    ///
128    /// Entries that fail to resolve or start do not abort the open; the
129    /// error is recorded and retrievable via [`Loader::last_error`], and the
130    /// offending entry simply has no (or a failed) fiber.
131    pub fn open(root: &Context, config: LoaderConfig) -> Result<Loader> {
132        let file = LoaderFile::open(&config.filename)?;
133        if !file.path().exists() {
134            if let Some(initial) = &config.initial {
135                file.write(initial)?;
136            }
137        }
138        // A corrupt or unreadable main file is fatal — booting an empty
139        // loader would silently discard the whole configuration. Import
140        // files keep the tolerant record-and-skip path inside `compose`.
141        let mut imports = HashMap::new();
142        let mut errors = Vec::new();
143        let (composed, _dirty) = compose(
144            &file,
145            &mut imports,
146            &mut HashSet::new(),
147            &mut HashSet::new(),
148            &mut errors,
149        )?;
150        let inner = Arc::new(LoaderInner {
151            root: root.clone(),
152            file,
153            tree: EntryTree::new(),
154            registry: Mutex::new(config.registry.unwrap_or_default()),
155            state: Mutex::new(LoaderState {
156                entries: HashMap::new(),
157                operating: 0,
158                last_error: errors.pop(),
159                _keep_alive: Vec::new(),
160            }),
161            operation: OperationLock::default(),
162            imports: Mutex::new(imports),
163            #[cfg(feature = "watch")]
164            watched: Mutex::new(HashSet::new()),
165            #[cfg(feature = "watch")]
166            watchers: Mutex::new(Vec::new()),
167            write_debounce: Mutex::new(config.write_debounce),
168        });
169        inner.tree.update(composed)?;
170        // Generated ids from the initial load are persisted lazily, on the
171        // first explicit write-back.
172
173        // The status listener routes plugin-initiated disposals (self-kill)
174        // back into the config file as `disabled: true`.
175        let weak = Arc::downgrade(&inner);
176        let status = root.events().on(
177            "internal/status",
178            move |event| {
179                if let Some(inner) = weak.upgrade() {
180                    handle_status(&inner, &event)?;
181                }
182                Ok(None)
183            },
184            EventOptions {
185                global: true,
186                ..EventOptions::default()
187            },
188        )?;
189        let service = root.provide_arc(
190            "loader",
191            Arc::new(LoaderHandle {
192                inner: Arc::downgrade(&inner),
193            }),
194        )?;
195        lock(&inner.state)._keep_alive = vec![status, service];
196
197        let loader = Loader { inner };
198        loader.start_all();
199        Ok(loader)
200    }
201
202    /// The root context the loader operates on.
203    pub fn context(&self) -> &Context {
204        &self.inner.root
205    }
206
207    /// The entry tree.
208    pub fn tree(&self) -> &EntryTree {
209        &self.inner.tree
210    }
211
212    /// The entry config file.
213    pub fn file(&self) -> &LoaderFile {
214        &self.inner.file
215    }
216
217    /// The plugin registry (a clone of the current state); populate it via
218    /// [`LoaderConfig::with_registry`] before open, or
219    /// [`Loader::register_plugin`] later.
220    pub fn registry(&self) -> PluginRegistry {
221        lock(&self.inner.registry).clone()
222    }
223
224    /// Register one plugin instance by its own name; picked up by the next
225    /// reload (or immediately for not-yet-started entries).
226    pub fn register_plugin<P: cordis::Plugin>(&self, plugin: P) {
227        lock(&self.inner.registry).register_plugin(plugin);
228    }
229
230    /// Register a handle factory under a name.
231    pub fn register<F>(&self, name: impl Into<String>, factory: F)
232    where
233        F: Fn() -> PluginHandle + Send + Sync + 'static,
234    {
235        lock(&self.inner.registry).register(name, factory);
236    }
237
238    /// The last background error recorded by the loader, if any.
239    pub fn last_error(&self) -> Option<String> {
240        lock(&self.inner.state).last_error.clone()
241    }
242
243    /// Set (or clear, with `None`) the debounce window for coalesced
244    /// config writes.
245    pub fn set_write_debounce(&self, delay: Option<Duration>) {
246        *lock(&self.inner.write_debounce) = delay;
247    }
248
249    /// The entry whose fiber is `fiber`, if the loader started it.
250    pub fn locate(&self, fiber: &Fiber) -> Option<Entry> {
251        let state = lock(&self.inner.state);
252        if let Some(uid) = fiber.uid() {
253            return state.entries.get(&uid).cloned();
254        }
255        state
256            .entries
257            .values()
258            .find(|entry| entry.fiber().is_some_and(|started| started.ptr_eq(fiber)))
259            .cloned()
260    }
261
262    /// Start every enabled, unstarted entry, parents before children.
263    fn start_all(&self) {
264        for entry in self.inner.tree.entries() {
265            if let Err(error) = start_entry(&self.inner, &entry) {
266                self.record_error(error);
267            }
268        }
269    }
270
271    /// Re-read the entry file and apply the difference to the fibers.
272    ///
273    /// Created entries start (parents first), removed subtrees stop, moved
274    /// entries restart under their new parent, redefined entries (plugin
275    /// name, inject declaration, or enabled flag changed) stop and start
276    /// with their new options, and updated entries are patched in place —
277    /// their config-only change never restarts the fiber. A patch the
278    /// plugin rejects leaves the fiber on its current config and is
279    /// retried by the next reload. Patches are never written back to the
280    /// file; only newly generated ids are persisted afterwards. The whole
281    /// reconcile runs under the loader's operation lock, serialized
282    /// against [`update_config`](Self::update_config) and
283    /// [`dispose`](Self::dispose).
284    pub fn reload(&self) -> Result<TreeDiff> {
285        let inner = &self.inner;
286        // Whole-reload exclusion: compose, diff, fiber transitions, and the
287        // id write-back must not interleave with update_config() or dispose().
288        let _operation = inner.operation.guard();
289        let mut imports = HashMap::new();
290        let mut errors = Vec::new();
291        let (composed, dirty) = match compose(
292            &inner.file,
293            &mut imports,
294            &mut HashSet::new(),
295            &mut HashSet::new(),
296            &mut errors,
297        ) {
298            Ok(composed) => composed,
299            Err(error) => {
300                // The current tree is still the last known-good state. In
301                // particular, do not feed an empty list to `EntryTree`:
302                // that would dispose every running plugin on a transient
303                // parse or I/O failure.
304                self.record_error(&error);
305                return Err(error.into());
306            }
307        };
308        for error in errors {
309            self.record_error(LoaderError::Include(
310                cordis_include::IncludeError::Message { message: error },
311            ));
312        }
313        let diff = inner.tree.update(composed)?;
314        *lock(&inner.imports) = imports;
315
316        for removed in &diff.removed {
317            if let Err(error) = stop_entry(inner, &removed.entry) {
318                self.record_error(error);
319            }
320        }
321        for entry in &diff.moved {
322            if let Err(error) = stop_entry(inner, entry) {
323                self.record_error(error);
324            }
325        }
326        for entry in &diff.redefined {
327            if let Err(error) = stop_entry(inner, entry) {
328                self.record_error(error);
329            }
330        }
331        for entry in &diff.updated {
332            if let Err(error) = patch_entry(inner, entry) {
333                self.record_error(error);
334            }
335        }
336        for entry in &diff.created {
337            if let Err(error) = start_entry(inner, entry) {
338                self.record_error(error);
339            }
340        }
341
342        // Restart what this reload stopped, parents first so re-parented
343        // entries find their group fibers: moved entries under their new
344        // parents, redefined entries with their new options, and updated
345        // entries that had no live fiber.
346        let mut restarts: Vec<&Entry> = diff
347            .moved
348            .iter()
349            .chain(&diff.redefined)
350            .chain(diff.updated.iter().filter(|entry| entry.fiber().is_none()))
351            .collect();
352        restarts.sort_by_key(|entry| entry_depth(entry));
353        for entry in restarts {
354            if let Err(error) = start_subtree(inner, entry) {
355                self.record_error(error);
356            }
357        }
358
359        // Entries created without explicit ids had one generated; persist
360        // it to the file that owns them so the next reload can match them.
361        if dirty {
362            write_back(inner)?;
363        }
364        #[cfg(feature = "watch")]
365        self.arm_import_watchers();
366        Ok(diff)
367    }
368
369    /// Change one entry's config at runtime: the fiber is updated (and
370    /// restarted when active) and the new config is persisted to the file.
371    pub fn update_config(&self, id: &str, config: Node) -> Result<()> {
372        let inner = &self.inner;
373        // Same exclusion as reload(): the fiber update, tree commit, and
374        // file write-back must land as one unit, or a concurrent reload
375        // could patch the fiber back to the file's previous content.
376        let _operation = inner.operation.guard();
377        let entry = inner.tree.resolve(id).ok_or_else(|| {
378            LoaderError::Include(cordis_include::IncludeError::EntryNotFound { id: id.to_owned() })
379        })?;
380        if let Some(fiber) = entry.fiber() {
381            fiber.update_value(Config::new(config.clone()))?;
382        }
383        let mut options = entry_options_with_children(&entry);
384        options.config = Some(config.clone());
385        inner
386            .tree
387            .update_entry(&entry.path(), options, None, None)?;
388        write_back(inner)?;
389        emit(
390            inner,
391            crate::events::CONFIG_UPDATE,
392            vec![Value::new(entry), Value::new(config)],
393        );
394        Ok(())
395    }
396
397    /// Stop every entry, stop watching files, and release the loader's
398    /// root-level effects (the status listener and the `loader` service).
399    /// The root context stays usable, and a fresh [`Loader::open`] on the
400    /// same root works afterwards.
401    pub fn dispose(&self) -> Result<()> {
402        let inner = &self.inner;
403        // Excluded against reload()/update_config() so entry teardown cannot
404        // interleave with a reconcile pass touching the same fibers.
405        let _operation = inner.operation.guard();
406        for entry in inner.tree.top_level() {
407            if let Err(error) = stop_entry(inner, &entry) {
408                self.record_error(error);
409            }
410        }
411        #[cfg(feature = "watch")]
412        {
413            lock(&inner.watched).clear();
414            lock(&inner.watchers).clear();
415        }
416        let keep_alive = std::mem::take(&mut lock(&inner.state)._keep_alive);
417        for effect in &keep_alive {
418            if let Err(error) = effect.dispose() {
419                self.record_error(LoaderError::Cordis(error));
420            }
421        }
422        Ok(())
423    }
424
425    /// Watch the entry file for external changes and reload on them
426    /// (`watch` feature). Reload errors are recorded in
427    /// [`Loader::last_error`].
428    #[cfg(feature = "watch")]
429    pub fn watch(&self) -> Result<cordis_include::FileWatcher> {
430        let loader = self.clone();
431        let watcher = self
432            .inner
433            .file
434            .watch(move || {
435                if let Err(error) = loader.reload() {
436                    loader.record_error(error);
437                }
438            })
439            .map_err(LoaderError::Include)?;
440        let main_path = std::fs::canonicalize(self.inner.file.path())
441            .unwrap_or_else(|_| self.inner.file.path().to_path_buf());
442        lock(&self.inner.watched).insert(main_path);
443        self.arm_import_watchers();
444        Ok(watcher)
445    }
446
447    /// Watch import files that appeared since the last arming; their
448    /// watchers live for the loader's lifetime (`watch` feature).
449    #[cfg(feature = "watch")]
450    fn arm_import_watchers(&self) {
451        for (path, file) in lock(&self.inner.imports).clone() {
452            if lock(&self.inner.watched).contains(&path) {
453                continue;
454            }
455            let loader = self.clone();
456            match file.watch(move || {
457                if let Err(error) = loader.reload() {
458                    loader.record_error(error);
459                }
460            }) {
461                Ok(watcher) => {
462                    lock(&self.inner.watched).insert(path);
463                    lock(&self.inner.watchers).push(watcher);
464                }
465                Err(error) => self.record_error(LoaderError::Include(error)),
466            }
467        }
468    }
469
470    fn record_error(&self, error: impl std::fmt::Display) {
471        lock(&self.inner.state).last_error = Some(error.to_string());
472    }
473}
474
475/// Increment `operating` for the lifetime of the guard, so disposals driven
476/// by the loader itself are not mistaken for self-kill.
477struct OperatingGuard<'a> {
478    state: &'a Mutex<LoaderState>,
479}
480
481impl<'a> OperatingGuard<'a> {
482    fn new(state: &'a Mutex<LoaderState>) -> Self {
483        lock(state).operating += 1;
484        Self { state }
485    }
486}
487
488impl Drop for OperatingGuard<'_> {
489    fn drop(&mut self) {
490        let mut state = lock(self.state);
491        state.operating = state.operating.saturating_sub(1);
492    }
493}
494
495/// Reentrancy-aware exclusion for the loader's state transitions.
496///
497/// Foreign threads block until the current transition finishes; acquisition
498/// from the *owning* thread passes through instead of deadlocking. That
499/// matters because loader events (`ENTRY_INIT`, `PARTIAL_DISPOSE`, patch
500/// events) run listener code inline, and a listener calling back into
501/// `reload()`/`update_config()` re-enters on the same thread — a plain
502/// `std::sync::Mutex` would deadlock there.
503#[derive(Default)]
504struct OperationLock {
505    state: Mutex<OperationState>,
506    released: Condvar,
507}
508
509#[derive(Default)]
510struct OperationState {
511    owner: Option<ThreadId>,
512    depth: usize,
513}
514
515impl OperationLock {
516    /// Acquire the lock, blocking only foreign threads.
517    fn guard(&self) -> OperationGuard<'_> {
518        let current = std::thread::current().id();
519        let mut state = lock(&self.state);
520        loop {
521            if state.owner.is_none_or(|owner| owner == current) {
522                state.owner = Some(current);
523                state.depth += 1;
524                return OperationGuard { lock: self };
525            }
526            let guard = self
527                .released
528                .wait(state)
529                .unwrap_or_else(|error| error.into_inner());
530            state = guard;
531        }
532    }
533}
534
535struct OperationGuard<'a> {
536    lock: &'a OperationLock,
537}
538
539impl Drop for OperationGuard<'_> {
540    fn drop(&mut self) {
541        let mut state = lock(&self.lock.state);
542        state.depth = state.depth.saturating_sub(1);
543        if state.depth == 0 {
544            state.owner = None;
545            drop(state);
546            self.lock.released.notify_all();
547        }
548    }
549}
550
551/// Emit a loader event; listener failures are recorded, never propagated
552/// into the state machine.
553fn emit(inner: &LoaderInner, name: &str, args: Vec<Value>) {
554    if let Err(error) = inner.root.events().emit(name, args) {
555        lock(&inner.state).last_error = Some(format!("{name} listener failed: {error}"));
556    }
557}
558
559/// Start one entry's fiber beneath its parent group's context.
560fn start_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
561    if !entry.enabled() || entry.fiber().is_some() {
562        return Ok(());
563    }
564    let name = entry.name();
565    let handle: PluginHandle = lock(&inner.registry)
566        .resolve(&name)
567        .map_err(LoaderError::Cordis)?;
568    let inject = entry.options().inject;
569    let handle = WithInject::wrap(handle, inject);
570    let config = entry.resolved_config()?.unwrap_or(Node::Null);
571    let parent_ctx = entry
572        .parent()
573        .and_then(|parent| parent.fiber())
574        .and_then(|fiber| fiber.context())
575        .unwrap_or_else(|| inner.root.clone());
576    let fiber = parent_ctx.plugin(handle, config);
577    let Some(uid) = fiber.uid() else {
578        // The parent context's registry rejected the start (its fiber was
579        // disposed concurrently, so the parent-effect registration failed
580        // and the new fiber came back with its uid cleared). Recording the
581        // rejected fiber here would wedge the entry forever: every later
582        // reload sees a fiber and skips the start. Leave the entry
583        // unstarted so the next reload retries it, and surface why.
584        return Err(LoaderError::Cordis(
585            fiber
586                .error()
587                .unwrap_or_else(|| CordisError::new(ErrorCode::InactiveEffect)),
588        ));
589    };
590    entry.set_fiber(Some(fiber.clone()));
591    lock(&inner.state).entries.insert(uid, entry.clone());
592    emit(
593        inner,
594        crate::events::ENTRY_INIT,
595        vec![Value::new(entry.clone())],
596    );
597    Ok(())
598}
599
600/// Stop one entry's fiber (children first for bookkeeping; disposal of a
601/// group cascades regardless).
602fn stop_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
603    for child in entry.children() {
604        stop_entry(inner, &child)?;
605    }
606    let Some(fiber) = entry.fiber() else {
607        return Ok(());
608    };
609    entry.set_fiber(None);
610    if let Some(uid) = fiber.uid() {
611        lock(&inner.state).entries.remove(&uid);
612    }
613    let _guard = OperatingGuard::new(&inner.state);
614    fiber.dispose().map_err(LoaderError::Cordis)
615}
616
617/// Apply a config-only change to a live entry by patching it in place.
618/// Structural changes (name, inject, enabled) arrive through
619/// `diff.redefined` and never here, so no identity comparison is needed.
620/// Entries without a live fiber are left to the reload's restart phase.
621fn patch_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
622    if !entry.enabled() {
623        return stop_entry(inner, entry);
624    }
625    let Some(fiber) = entry.fiber() else {
626        return Ok(());
627    };
628    let new_config = entry.resolved_config()?.unwrap_or(Node::Null);
629    let current = fiber
630        .config()
631        .downcast::<Node>()
632        .ok()
633        .map(|node| (*node).clone());
634    if current.as_ref() != Some(&new_config) {
635        emit(
636            inner,
637            crate::events::BEFORE_PATCH,
638            vec![Value::new(entry.clone())],
639        );
640        if let Err(error) = fiber.update_value(Config::new(new_config)) {
641            // tree.update() already committed the new options before this
642            // patch ran. Rolling the entry's stored config back to what the
643            // fiber actually runs keeps the tree honest and — crucially —
644            // makes the next reload's diff see a change again, so a config
645            // that failed validation is retried instead of silently pinning
646            // the fiber to the stale config forever.
647            if let Some(old_config) = current {
648                let mut options = entry_options_with_children(entry);
649                options.config = Some(old_config);
650                if let Err(revert) = inner.tree.update_entry(&entry.path(), options, None, None) {
651                    lock(&inner.state).last_error = Some(format!(
652                        "failed to roll back config of {}: {revert}",
653                        entry.path()
654                    ));
655                }
656            }
657            return Err(LoaderError::Cordis(error));
658        }
659        emit(
660            inner,
661            crate::events::AFTER_PATCH,
662            vec![Value::new(entry.clone())],
663        );
664    }
665    Ok(())
666}
667
668/// (Re)start an entry and its descendants, parents first; `start_entry`
669/// itself skips disabled entries and entries that already run.
670fn start_subtree(inner: &LoaderInner, entry: &Entry) -> Result<()> {
671    start_entry(inner, entry)?;
672    for child in entry.children() {
673        start_subtree(inner, &child)?;
674    }
675    Ok(())
676}
677
678/// Distance from the tree root, for restarting stopped entries parents
679/// first.
680fn entry_depth(entry: &Entry) -> usize {
681    let mut depth = 0;
682    let mut current = entry.clone();
683    while let Some(parent) = current.parent() {
684        depth += 1;
685        current = parent;
686    }
687    depth
688}
689
690/// Serialize an entry together with its live subtree (used by update paths
691/// that must not disturb children).
692fn entry_options_with_children(entry: &Entry) -> EntryOptions {
693    let mut options = entry.options();
694    options.group = entry
695        .children()
696        .iter()
697        .map(entry_options_with_children)
698        .collect();
699    options
700}
701
702/// Persist the current tree across every involved file, preserving
703/// unknown top-level keys. Import subtrees are stripped from their parent
704/// file and written to the file they came from.
705fn write_back(inner: &LoaderInner) -> Result<()> {
706    let mut jobs: Vec<(LoaderFile, Vec<EntryOptions>)> = vec![(
707        inner.file.clone(),
708        inner
709            .tree
710            .top_level()
711            .iter()
712            .map(to_stripped_options)
713            .collect(),
714    )];
715    for entry in inner.tree.entries() {
716        if entry.options().import_url().is_some() {
717            if let Some(file) = lock(&inner.imports).get(&import_canonical(inner, &entry)) {
718                let children = entry.children().iter().map(to_stripped_options).collect();
719                jobs.push((file.clone(), children));
720            }
721        }
722    }
723    let debounce = *lock(&inner.write_debounce);
724    for (file, entries) in jobs {
725        let mut document = file.read()?;
726        document.entries = entries;
727        match debounce {
728            Some(delay) => file.write_deferred(document, delay),
729            None => file.write(&document)?,
730        }
731    }
732    Ok(())
733}
734
735/// The entry's full options with import descendants cut off: an import
736/// entry keeps its own fields but drops the children mounted from its
737/// file, at any depth.
738fn to_stripped_options(entry: &Entry) -> EntryOptions {
739    fn strip(options: &mut EntryOptions) {
740        if options.import_url().is_some() {
741            // Everything below an import comes from its own file.
742            options.group.clear();
743            return;
744        }
745        options.group.retain(|child| child.import_url().is_none());
746        for child in &mut options.group {
747            strip(child);
748        }
749    }
750    let mut options = entry_options_with_children(entry);
751    strip(&mut options);
752    options
753}
754
755/// Resolve an import url against the directory of the file that contains
756/// the import entry.
757fn import_path(base_file: &LoaderFile, url: &str) -> PathBuf {
758    let direct = Path::new(url);
759    if direct.is_absolute() {
760        return direct.to_path_buf();
761    }
762    match base_file.path().parent() {
763        Some(parent) => parent.join(url),
764        None => direct.to_path_buf(),
765    }
766}
767
768/// The canonical path under which an import entry's file is registered.
769fn import_canonical(inner: &LoaderInner, entry: &Entry) -> PathBuf {
770    let url = entry.options().import_url().unwrap_or_default().to_owned();
771    let path = import_path(&inner.file, &url);
772    std::fs::canonicalize(&path).unwrap_or(path)
773}
774
775/// Read `file` and recursively mount import subtrees: every `import`
776/// entry's `group` becomes the entries of the file its `url` names, so one
777/// `EntryTree::update` diffs across all files uniformly. Returns the
778/// composed top-level entries and whether any file carried entries without
779/// ids (whose generated ids need persisting).
780///
781/// `active` holds the files on the current import chain (cycle detection);
782/// `mounted` holds every file mounted anywhere in this compose. The entry
783/// tree keys entries by globally unique id, so the import graph must be a
784/// tree: real cycles and diamonds (the same file mounted twice) are both
785/// reported and their reference dropped, but with distinct diagnoses.
786fn compose(
787    file: &LoaderFile,
788    imports: &mut HashMap<PathBuf, LoaderFile>,
789    active: &mut HashSet<PathBuf>,
790    mounted: &mut HashSet<PathBuf>,
791    errors: &mut Vec<String>,
792) -> cordis_include::Result<(Vec<EntryOptions>, bool)> {
793    // Reading the file passed directly to this call is not recoverable here:
794    // callers loading the main file propagate the error, while callers
795    // mounting an import catch it below and retain the tolerant skip path.
796    let document = file.read()?;
797    let mut entries = Vec::with_capacity(document.entries.len());
798    let mut dirty = document.entries.iter().any(|options| options.id.is_none());
799    for mut options in document.entries {
800        if let Some(url) = options.import_url().map(str::to_owned) {
801            let path = import_path(file, &url);
802            let canonical = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
803            if !active.insert(canonical.clone()) {
804                errors.push(format!("import cycle detected at {}", path.display()));
805                // Drop the cyclic reference: keeping a copy of the entry
806                // would duplicate its id inside the composed tree.
807                continue;
808            }
809            if !mounted.insert(canonical.clone()) {
810                errors.push(format!(
811                    "duplicate import: {} is already mounted elsewhere; \
812                     the import graph must be a tree",
813                    path.display()
814                ));
815                active.remove(&canonical);
816                continue;
817            }
818            match LoaderFile::open(&path) {
819                Ok(sub_file) => {
820                    match compose(&sub_file, imports, active, mounted, errors) {
821                        Ok((sub_entries, sub_dirty)) => {
822                            dirty |= sub_dirty;
823                            options.group = sub_entries;
824                        }
825                        Err(error) => {
826                            errors.push(format!(
827                                "cannot read import {}: {error}",
828                                sub_file.path().display()
829                            ));
830                            // Never trust children embedded in an import
831                            // marker when its owning file could not be read.
832                            options.group.clear();
833                        }
834                    }
835                    imports.insert(canonical.clone(), sub_file);
836                }
837                Err(error) => errors.push(format!(
838                    "cannot open import {} ({}: {error})",
839                    path.display(),
840                    file.path().display()
841                )),
842            }
843            // A file is "active" only while its own subtree composes, so
844            // sibling imports of different files never look like cycles.
845            active.remove(&canonical);
846        }
847        entries.push(options);
848    }
849    Ok((entries, dirty))
850}
851
852/// Route `internal/status` disposals: a fiber that reached `Disposed`
853/// outside loader operation was killed by its own plugin, so record
854/// `disabled: true` in the tree and persist it.
855///
856/// The status event fires while the dying fiber still holds its transition
857/// mutex, so the persistence itself — a tree mutation, file serialize +
858/// fsync + rename, and `PARTIAL_DISPOSE` listeners — is deferred to a
859/// short-lived thread. Running it inline would stretch that critical
860/// section across disk I/O and arbitrary user code, making other threads'
861/// restart/dispose on the same fiber time out on stalls that have nothing
862/// to do with the fiber's own teardown.
863fn handle_status(inner: &Arc<LoaderInner>, event: &cordis::Event) -> cordis::EventResult {
864    let Some(fiber) = event.arg::<Fiber>(0).ok().flatten() else {
865        return Ok(None);
866    };
867    if fiber.state() != FiberState::Disposed {
868        return Ok(None);
869    }
870    if lock(&inner.state).operating > 0 {
871        return Ok(None);
872    }
873    let Some(entry) = lock(&inner.state)
874        .entries
875        .values()
876        .find(|entry| entry.fiber().is_some_and(|started| started.ptr_eq(&fiber)))
877        .cloned()
878    else {
879        return Ok(None);
880    };
881    let deferred = std::thread::Builder::new()
882        .name("cordis-self-dispose".to_owned())
883        .spawn({
884            // A strong reference keeps the loader alive until the record
885            // lands, even if the caller drops every Loader handle at once.
886            let inner = Arc::clone(inner);
887            let entry = entry.clone();
888            move || {
889                // Serialized with reload()/update_config()/dispose() so the
890                // self-kill write-back cannot interleave with a reconcile.
891                let _operation = inner.operation.guard();
892                if let Err(error) = persist_self_dispose(&inner, &entry) {
893                    lock(&inner.state).last_error = Some(error.to_string());
894                }
895            }
896        });
897    match deferred {
898        Ok(_join) => {}
899        Err(_) => {
900            // Could not spawn a thread: persist inline rather than losing
901            // the self-kill record.
902            let _operation = inner.operation.guard();
903            if let Err(error) = persist_self_dispose(inner, &entry) {
904                lock(&inner.state).last_error = Some(error.to_string());
905            }
906        }
907    }
908    Ok(None)
909}
910
911/// A plugin disposed itself: unmap the entry and persist `disabled: true`.
912fn persist_self_dispose(inner: &LoaderInner, entry: &Entry) -> Result<()> {
913    {
914        let mut state = lock(&inner.state);
915        let key = state
916            .entries
917            .iter()
918            .find(|(_, mapped)| Entry::ptr_eq(mapped, entry))
919            .map(|(uid, _)| *uid);
920        if let Some(uid) = key {
921            state.entries.remove(&uid);
922        }
923    }
924    entry.set_fiber(None);
925    let mut options = entry_options_with_children(entry);
926    options.disabled = true;
927    inner
928        .tree
929        .update_entry(&entry.path(), options, None, None)?;
930    write_back(inner)?;
931    emit(
932        inner,
933        crate::events::PARTIAL_DISPOSE,
934        vec![Value::new(entry.clone())],
935    );
936    Ok(())
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942    use cordis::{Inject, PluginOutput, plugin_sync};
943
944    /// Regression (#36): when the parent group's fiber dies before a child
945    /// entry starts, the registry rejects the new fiber (uid cleared,
946    /// state Disposed). start_entry must surface the rejection and leave
947    /// the entry without a fiber — recording the rejected fiber wedged the
948    /// entry forever, since every later reload saw a fiber and skipped the
949    /// start.
950    #[test]
951    fn rejected_start_leaves_the_entry_retryable() {
952        let path = std::env::temp_dir().join(format!(
953            "cordis-loader-rejected-start-{}-{}.yml",
954            std::process::id(),
955            std::time::SystemTime::now()
956                .duration_since(std::time::UNIX_EPOCH)
957                .map(|elapsed| elapsed.as_nanos() as u64)
958                .unwrap_or(0)
959        ));
960        let _ = std::fs::remove_file(&path);
961        let mut registry = PluginRegistry::new();
962        registry.register("worker", || {
963            plugin_sync::<Node, _>("worker", Inject::default(), |_, _| Ok(PluginOutput::none()))
964        });
965        let root = Context::new();
966        let loader = Loader::open(
967            &root,
968            LoaderConfig::new(&path)
969                .with_registry(registry)
970                .with_initial(cordis_include::Document::with_entries(vec![
971                    EntryOptions::new("group")
972                        .with_id("g1")
973                        .with_group(vec![EntryOptions::new("worker").with_id("c1")]),
974                ])),
975        )
976        .unwrap();
977        let inner = &loader.inner;
978        let group = inner.tree.resolve("g1").unwrap();
979        let child = inner.tree.resolve("g1:c1").unwrap();
980        assert!(group.fiber().is_some() && child.fiber().is_some());
981
982        // Kill the parent group while the loader looks away (no self-kill
983        // bookkeeping), then model the child as not-yet-started.
984        {
985            let _operating = OperatingGuard::new(&inner.state);
986            group.fiber().unwrap().dispose().unwrap();
987        }
988        child.set_fiber(None);
989
990        let result = start_entry(inner, &child);
991        assert!(result.is_err(), "the registry rejection must surface");
992        assert!(child.fiber().is_none(), "no rejected fiber recorded");
993
994        // Still eligible: retrying fails the same way instead of silently
995        // doing nothing because a dead fiber occupies the entry.
996        assert!(start_entry(inner, &child).is_err());
997        assert!(child.fiber().is_none());
998
999        drop(loader);
1000        let _ = std::fs::remove_file(&path);
1001    }
1002}