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