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. The
677/// enabled check resolves `!!js` disabled expressions (own slot and every
678/// ancestor's); an expression that fails to evaluate is a start failure,
679/// recorded by the caller like a resolve failure.
680fn start_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
681    if entry.fiber().is_some() {
682        return Ok(());
683    }
684    if !entry.resolved_enabled()? {
685        return Ok(());
686    }
687    let name = entry.name();
688    let handle: PluginHandle = lock(&inner.registry)
689        .resolve(&name)
690        .map_err(LoaderError::Cordis)?;
691    let inject = entry.options().inject;
692    let handle = WithInject::wrap(handle, inject);
693    let config = entry.resolved_config()?.unwrap_or(Node::Null);
694    let parent_ctx = entry
695        .parent()
696        .and_then(|parent| parent.fiber())
697        .and_then(|fiber| fiber.context())
698        .unwrap_or_else(|| inner.root.clone());
699    let fiber = parent_ctx.plugin(handle, config);
700    let Some(uid) = fiber.uid() else {
701        // The parent context's registry rejected the start (its fiber was
702        // disposed concurrently, so the parent-effect registration failed
703        // and the new fiber came back with its uid cleared). Recording the
704        // rejected fiber here would wedge the entry forever: every later
705        // reload sees a fiber and skips the start. Leave the entry
706        // unstarted so the next reload retries it, and surface why.
707        return Err(LoaderError::Cordis(
708            fiber
709                .error()
710                .unwrap_or_else(|| CordisError::new(ErrorCode::InactiveEffect)),
711        ));
712    };
713    entry.set_fiber(Some(fiber.clone()));
714    lock(&inner.state).entries.insert(uid, entry.clone());
715    emit(
716        inner,
717        crate::events::ENTRY_INIT,
718        vec![Value::new(entry.clone())],
719    );
720    Ok(())
721}
722
723/// Stop one entry's fiber (children first for bookkeeping; disposal of a
724/// group cascades regardless).
725fn stop_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
726    for child in entry.children() {
727        stop_entry(inner, &child)?;
728    }
729    let Some(fiber) = entry.fiber() else {
730        return Ok(());
731    };
732    entry.set_fiber(None);
733    if let Some(uid) = fiber.uid() {
734        lock(&inner.state).entries.remove(&uid);
735    }
736    let _guard = OperatingGuard::new(&inner.state);
737    fiber.dispose().map_err(LoaderError::Cordis)
738}
739
740/// Apply a config-only change to a live entry by patching it in place.
741/// Structural changes (name, inject, enabled) arrive through
742/// `diff.redefined` and never here, so no identity comparison is needed.
743/// Entries without a live fiber are left to the reload's restart phase.
744/// A `!!js` disabled expression that fails to evaluate propagates the
745/// error and leaves the fiber on its current config, retried by the next
746/// reload.
747fn patch_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
748    if !entry.resolved_enabled()? {
749        return stop_entry(inner, entry);
750    }
751    let Some(fiber) = entry.fiber() else {
752        return Ok(());
753    };
754    let new_config = entry.resolved_config()?.unwrap_or(Node::Null);
755    let current = fiber
756        .config()
757        .downcast::<Node>()
758        .ok()
759        .map(|node| (*node).clone());
760    if current.as_ref() != Some(&new_config) {
761        emit(
762            inner,
763            crate::events::BEFORE_PATCH,
764            vec![Value::new(entry.clone())],
765        );
766        if let Err(error) = fiber.update_value(Config::new(new_config)) {
767            // tree.update() already committed the new options before this
768            // patch ran. Rolling the entry's stored config back to what the
769            // fiber actually runs keeps the tree honest and — crucially —
770            // makes the next reload's diff see a change again, so a config
771            // that failed validation is retried instead of silently pinning
772            // the fiber to the stale config forever.
773            if let Some(old_config) = current {
774                let mut options = entry_options_with_children(entry);
775                options.config = Some(old_config);
776                if let Err(revert) = inner.tree.update_entry(&entry.path(), options, None, None) {
777                    lock(&inner.state).last_error = Some(format!(
778                        "failed to roll back config of {}: {revert}",
779                        entry.path()
780                    ));
781                }
782            }
783            return Err(LoaderError::Cordis(error));
784        }
785        emit(
786            inner,
787            crate::events::AFTER_PATCH,
788            vec![Value::new(entry.clone())],
789        );
790    }
791    Ok(())
792}
793
794/// (Re)start an entry and its descendants, parents first; `start_entry`
795/// itself skips disabled entries and entries that already run.
796fn start_subtree(inner: &LoaderInner, entry: &Entry) -> Result<()> {
797    start_entry(inner, entry)?;
798    for child in entry.children() {
799        start_subtree(inner, &child)?;
800    }
801    Ok(())
802}
803
804/// Distance from the tree root, for restarting stopped entries parents
805/// first.
806fn entry_depth(entry: &Entry) -> usize {
807    let mut depth = 0;
808    let mut current = entry.clone();
809    while let Some(parent) = current.parent() {
810        depth += 1;
811        current = parent;
812    }
813    depth
814}
815
816/// Serialize an entry together with its live subtree (used by update paths
817/// that must not disturb children).
818fn entry_options_with_children(entry: &Entry) -> EntryOptions {
819    let mut options = entry.options();
820    options.group = entry
821        .children()
822        .iter()
823        .map(entry_options_with_children)
824        .collect();
825    options
826}
827
828/// Persist the current tree across every involved file, preserving
829/// unknown top-level keys. Import subtrees are stripped from their parent
830/// file and written to the file they came from.
831fn write_back(inner: &LoaderInner) -> Result<()> {
832    let mut jobs: Vec<(LoaderFile, Vec<EntryOptions>)> = vec![(
833        inner.file.clone(),
834        inner
835            .tree
836            .top_level()
837            .iter()
838            .map(to_stripped_options)
839            .collect(),
840    )];
841    for entry in inner.tree.entries() {
842        if entry.options().import_url().is_some() {
843            if let Some(file) = lock(&inner.imports).get(&import_canonical(inner, &entry)) {
844                let children = entry.children().iter().map(to_stripped_options).collect();
845                jobs.push((file.clone(), children));
846            }
847        }
848    }
849    let debounce = *lock(&inner.write_debounce);
850    for (file, entries) in jobs {
851        let mut document = file.read()?;
852        document.entries = entries;
853        match debounce {
854            Some(delay) => file.write_deferred(document, delay),
855            None => file.write(&document)?,
856        }
857    }
858    Ok(())
859}
860
861/// The entry's full options with import descendants cut off: an import
862/// entry keeps its own fields but drops the children mounted from its
863/// file, at any depth.
864fn to_stripped_options(entry: &Entry) -> EntryOptions {
865    fn strip(options: &mut EntryOptions) {
866        if options.import_url().is_some() {
867            // Everything below an import comes from its own file.
868            options.group.clear();
869            return;
870        }
871        options.group.retain(|child| child.import_url().is_none());
872        for child in &mut options.group {
873            strip(child);
874        }
875    }
876    let mut options = entry_options_with_children(entry);
877    strip(&mut options);
878    options
879}
880
881/// Resolve an import url against the directory of the file that contains
882/// the import entry.
883fn import_path(base_file: &LoaderFile, url: &str) -> PathBuf {
884    let direct = Path::new(url);
885    if direct.is_absolute() {
886        return direct.to_path_buf();
887    }
888    match base_file.path().parent() {
889        Some(parent) => parent.join(url),
890        None => direct.to_path_buf(),
891    }
892}
893
894/// The canonical path under which an import entry's file is registered.
895fn import_canonical(inner: &LoaderInner, entry: &Entry) -> PathBuf {
896    let url = entry.options().import_url().unwrap_or_default().to_owned();
897    let path = import_path(&inner.file, &url);
898    std::fs::canonicalize(&path).unwrap_or(path)
899}
900
901/// Read `file` and recursively mount import subtrees: every `import`
902/// entry's `group` becomes the entries of the file its `url` names, so one
903/// `EntryTree::update` diffs across all files uniformly. Returns the
904/// composed top-level entries and whether any file carried entries without
905/// ids (whose generated ids need persisting).
906///
907/// Reading the file passed directly to this call is not recoverable here:
908/// callers loading the main file propagate the error, while callers
909/// mounting an import catch it above themselves and retain the tolerant
910/// skip path.
911fn compose(
912    file: &LoaderFile,
913    imports: &mut HashMap<PathBuf, LoaderFile>,
914    active: &mut HashSet<PathBuf>,
915    mounted: &mut HashSet<PathBuf>,
916    errors: &mut Vec<String>,
917) -> cordis_include::Result<(Vec<EntryOptions>, bool)> {
918    let document = file.read()?;
919    Ok(compose_entries(
920        document.entries,
921        file,
922        imports,
923        active,
924        mounted,
925        errors,
926    ))
927}
928
929/// Mount import subtrees under base rows supplied in memory — the entries
930/// half of [`compose`], used by document-backed composition sources
931/// ([`LoaderConfig::with_document`], [`Loader::update`]). `base` resolves
932/// relative import urls. Infallible: import failures follow the tolerant
933/// record-and-skip path.
934///
935/// `active` holds the files on the current import chain (cycle detection);
936/// `mounted` holds every file mounted anywhere in this compose. The entry
937/// tree keys entries by globally unique id, so the import graph must be a
938/// tree: real cycles and diamonds (the same file mounted twice) are both
939/// reported and their reference dropped, but with distinct diagnoses.
940fn compose_entries(
941    entries: Vec<EntryOptions>,
942    base: &LoaderFile,
943    imports: &mut HashMap<PathBuf, LoaderFile>,
944    active: &mut HashSet<PathBuf>,
945    mounted: &mut HashSet<PathBuf>,
946    errors: &mut Vec<String>,
947) -> (Vec<EntryOptions>, bool) {
948    let mut composed = Vec::with_capacity(entries.len());
949    let mut dirty = entries.iter().any(|options| options.id.is_none());
950    for mut options in entries {
951        if let Some(url) = options.import_url().map(str::to_owned) {
952            let path = import_path(base, &url);
953            let canonical = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
954            if !active.insert(canonical.clone()) {
955                errors.push(format!("import cycle detected at {}", path.display()));
956                // Drop the cyclic reference: keeping a copy of the entry
957                // would duplicate its id inside the composed tree.
958                continue;
959            }
960            if !mounted.insert(canonical.clone()) {
961                errors.push(format!(
962                    "duplicate import: {} is already mounted elsewhere; \
963                     the import graph must be a tree",
964                    path.display()
965                ));
966                active.remove(&canonical);
967                continue;
968            }
969            match LoaderFile::open(&path) {
970                Ok(sub_file) => {
971                    match compose(&sub_file, imports, active, mounted, errors) {
972                        Ok((sub_entries, sub_dirty)) => {
973                            dirty |= sub_dirty;
974                            options.group = sub_entries;
975                        }
976                        Err(error) => {
977                            errors.push(format!(
978                                "cannot read import {}: {error}",
979                                sub_file.path().display()
980                            ));
981                            // Never trust children embedded in an import
982                            // marker when its owning file could not be read.
983                            options.group.clear();
984                        }
985                    }
986                    imports.insert(canonical.clone(), sub_file);
987                }
988                Err(error) => errors.push(format!(
989                    "cannot open import {} ({}: {error})",
990                    path.display(),
991                    base.path().display()
992                )),
993            }
994            // A file is "active" only while its own subtree composes, so
995            // sibling imports of different files never look like cycles.
996            active.remove(&canonical);
997        }
998        composed.push(options);
999    }
1000    (composed, dirty)
1001}
1002
1003/// Route `internal/status` disposals: a fiber that reached `Disposed`
1004/// outside loader operation was killed by its own plugin, so record
1005/// `disabled: true` in the tree and persist it.
1006///
1007/// The status event fires while the dying fiber still holds its transition
1008/// mutex, so the persistence itself — a tree mutation, file serialize +
1009/// fsync + rename, and `PARTIAL_DISPOSE` listeners — is deferred to a
1010/// short-lived thread. Running it inline would stretch that critical
1011/// section across disk I/O and arbitrary user code, making other threads'
1012/// restart/dispose on the same fiber time out on stalls that have nothing
1013/// to do with the fiber's own teardown.
1014fn handle_status(inner: &Arc<LoaderInner>, event: &cordis::Event) -> cordis::EventResult {
1015    let Some(fiber) = event.arg::<Fiber>(0).ok().flatten() else {
1016        return Ok(None);
1017    };
1018    if fiber.state() != FiberState::Disposed {
1019        return Ok(None);
1020    }
1021    if lock(&inner.state).operating > 0 {
1022        return Ok(None);
1023    }
1024    let Some(entry) = lock(&inner.state)
1025        .entries
1026        .values()
1027        .find(|entry| entry.fiber().is_some_and(|started| started.ptr_eq(&fiber)))
1028        .cloned()
1029    else {
1030        return Ok(None);
1031    };
1032    let deferred = std::thread::Builder::new()
1033        .name("cordis-self-dispose".to_owned())
1034        .spawn({
1035            // A strong reference keeps the loader alive until the record
1036            // lands, even if the caller drops every Loader handle at once.
1037            let inner = Arc::clone(inner);
1038            let entry = entry.clone();
1039            move || {
1040                // Serialized with reload()/update_config()/dispose() so the
1041                // self-kill write-back cannot interleave with a reconcile.
1042                let _operation = inner.operation.guard();
1043                if let Err(error) = persist_self_dispose(&inner, &entry) {
1044                    lock(&inner.state).last_error = Some(error.to_string());
1045                }
1046            }
1047        });
1048    match deferred {
1049        Ok(_join) => {}
1050        Err(_) => {
1051            // Could not spawn a thread: persist inline rather than losing
1052            // the self-kill record.
1053            let _operation = inner.operation.guard();
1054            if let Err(error) = persist_self_dispose(inner, &entry) {
1055                lock(&inner.state).last_error = Some(error.to_string());
1056            }
1057        }
1058    }
1059    Ok(None)
1060}
1061
1062/// A plugin disposed itself: unmap the entry and persist `disabled: true`.
1063fn persist_self_dispose(inner: &LoaderInner, entry: &Entry) -> Result<()> {
1064    {
1065        let mut state = lock(&inner.state);
1066        let key = state
1067            .entries
1068            .iter()
1069            .find(|(_, mapped)| Entry::ptr_eq(mapped, entry))
1070            .map(|(uid, _)| *uid);
1071        if let Some(uid) = key {
1072            state.entries.remove(&uid);
1073        }
1074    }
1075    entry.set_fiber(None);
1076    let mut options = entry_options_with_children(entry);
1077    // The static flag overwrites any `!!js` expression the slot held.
1078    // Upstream keeps the raw expression in the options; this port trades
1079    // that for the dead entry's final state — the draft is regenerated
1080    // (and the expression restored) on every recomposition anyway.
1081    options.disabled = cordis_include::Disabled::Flag(true);
1082    inner
1083        .tree
1084        .update_entry(&entry.path(), options, None, None)?;
1085    write_back(inner)?;
1086    emit(
1087        inner,
1088        crate::events::PARTIAL_DISPOSE,
1089        vec![Value::new(entry.clone())],
1090    );
1091    Ok(())
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096    use super::*;
1097    use cordis::{Inject, PluginOutput, plugin_sync};
1098
1099    /// Regression (#36): when the parent group's fiber dies before a child
1100    /// entry starts, the registry rejects the new fiber (uid cleared,
1101    /// state Disposed). start_entry must surface the rejection and leave
1102    /// the entry without a fiber — recording the rejected fiber wedged the
1103    /// entry forever, since every later reload saw a fiber and skipped the
1104    /// start.
1105    #[test]
1106    fn rejected_start_leaves_the_entry_retryable() {
1107        let path = std::env::temp_dir().join(format!(
1108            "cordis-loader-rejected-start-{}-{}.yml",
1109            std::process::id(),
1110            std::time::SystemTime::now()
1111                .duration_since(std::time::UNIX_EPOCH)
1112                .map(|elapsed| elapsed.as_nanos() as u64)
1113                .unwrap_or(0)
1114        ));
1115        let _ = std::fs::remove_file(&path);
1116        let mut registry = PluginRegistry::new();
1117        registry.register("worker", || {
1118            plugin_sync::<Node, _>("worker", Inject::default(), |_, _| Ok(PluginOutput::none()))
1119        });
1120        let root = Context::new();
1121        let loader = Loader::open(
1122            &root,
1123            LoaderConfig::new(&path)
1124                .with_registry(registry)
1125                .with_initial(cordis_include::Document::with_entries(vec![
1126                    EntryOptions::new("group")
1127                        .with_id("g1")
1128                        .with_group(vec![EntryOptions::new("worker").with_id("c1")]),
1129                ])),
1130        )
1131        .unwrap();
1132        let inner = &loader.inner;
1133        let group = inner.tree.resolve("g1").unwrap();
1134        let child = inner.tree.resolve("g1:c1").unwrap();
1135        assert!(group.fiber().is_some() && child.fiber().is_some());
1136
1137        // Kill the parent group while the loader looks away (no self-kill
1138        // bookkeeping), then model the child as not-yet-started.
1139        {
1140            let _operating = OperatingGuard::new(&inner.state);
1141            group.fiber().unwrap().dispose().unwrap();
1142        }
1143        child.set_fiber(None);
1144
1145        let result = start_entry(inner, &child);
1146        assert!(result.is_err(), "the registry rejection must surface");
1147        assert!(child.fiber().is_none(), "no rejected fiber recorded");
1148
1149        // Still eligible: retrying fails the same way instead of silently
1150        // doing nothing because a dead fiber occupies the entry.
1151        assert!(start_entry(inner, &child).is_err());
1152        assert!(child.fiber().is_none());
1153
1154        drop(loader);
1155        let _ = std::fs::remove_file(&path);
1156    }
1157}