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