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