cordis_loader/lib.rs
1//! Config-file driven plugin loader for
2//! [cordis-rs](https://crates.io/crates/cordis-rs).
3//!
4//! This crate is the assembly half of porting upstream Cordis' loader: it
5//! connects `cordis-include`'s entry trees to cordis fibers. Everything a
6//! config-driven cordis application needs is re-exported here — depend on
7//! `cordis-loader` alone.
8//!
9//! # How it works
10//!
11//! - **Static registry** ([`PluginRegistry`]) replaces upstream's dynamic
12//! `import(name)`: register plugins at startup, entries resolve by name.
13//! The `group` builtin is pre-registered. With the `dynamic` feature,
14//! names can also resolve to plugins compiled as dynamic libraries (see
15//! the [`dynamic`] module).
16//! - **Startup**: [`Loader::open`] reads the entry file (writing
17//! `initial` when missing), builds the [`EntryTree`], and starts every
18//! enabled entry — group entries as `cordis_group::Group` fibers, children
19//! beneath their parent group's context, so disposing a group cascades.
20//! - **Config**: entries carry a `cordis_include::Node` config (never `()`);
21//! loader plugins read it via `config.downcast::<Node>()`. `${{ env.X }}`
22//! templates expand at hand-off time; the file keeps the raw text.
23//! - **Reload** ([`Loader::reload`], wired to the `watch` feature):
24//! re-read the file, diff the tree, and reconcile fibers — created entries
25//! start, removed subtrees stop, moved entries restart under their new
26//! parent, entries whose plugin name / inject declaration / enabled flag
27//! changed stop and restart with their new options, and config-only
28//! changes patch in place via `Fiber::update_value`. A corrupt or
29//! unreadable main file fails the operation instead of silently booting
30//! an empty tree (import files keep a tolerant record-and-skip path).
31//! - **Document sources** ([`LoaderConfig::with_document`],
32//! [`Loader::update`]): compose from an in-memory document instead of the
33//! entry file. Boot then never reads or writes the file — concurrent
34//! boots on one shared draft cannot race, and the directory may stay
35//! read-only — while reloads recompose from the stored document (import
36//! files are still read). `update` reconciles a fresh composition with
37//! the same diff → stop → patch → start machinery but no write-back,
38//! making it the HMR primitive for layer-based composition: a watcher
39//! recomposes and hands the result over.
40//! - **Inject**: an entry's `inject` list is merged into the plugin's own
41//! declaration, so the core fiber machinery reconciles entries when
42//! services come and go — "hot-swapped service restarts its dependents"
43//! for free.
44//! - **Self-kill vs. removal**: a fiber that reaches `Disposed` outside
45//! loader operation was killed by its own plugin; the loader persists
46//! `disabled: true` for that entry shortly after, deferred off the dying
47//! fiber's transition lock. Removing an entry from the file just stops
48//! it.
49//! - **Write-back**: [`Loader::update_config`] is the runtime entry point —
50//! it updates the fiber *and* persists the config. Reloads apply their
51//! patches without writing them back; only newly generated ids are
52//! persisted. For a document-backed loader the entry file is a pure
53//! write-back draft: rows materialized into it never re-enter the
54//! composition. `reload`, `update`, `update_config`, and `dispose`
55//! serialize through one operation lock (reentrant from event listeners),
56//! so a watch-thread reload cannot interleave with a plugin-thread
57//! `update_config`.
58//!
59//! # Example
60//!
61//! ```
62//! use cordis_loader::{Loader, LoaderConfig, PluginRegistry};
63//!
64//! let root = cordis::Context::new();
65//! let mut registry = PluginRegistry::new();
66//! // registry.register_plugin(my_plugin); // your plugins, by name
67//! let config = LoaderConfig::new("cordis.yml").with_registry(registry);
68//! let loader = Loader::open(&root, config)?;
69//! # assert!(loader.tree().entries().is_empty());
70//! # Ok::<(), cordis_loader::LoaderError>(())
71//! ```
72//!
73//! Register the plugins first (the `group` builtin is pre-registered), then
74//! open; plugins registered later via [`Loader::register_plugin`] are picked
75//! up by the next [`Loader::reload`].
76//!
77//! # Imports
78//!
79//! An entry with `name: import` and `config: { url: "…" }` mounts another
80//! config file as its subtree. Reloads compose every involved file into
81//! one tree (so diffs and id reuse work across files), while write-back
82//! decomposes: mounted children are persisted to the file they came from,
83//! never to the importing file. Import cycles are reported through
84//! [`Loader::last_error`] instead of recursing. With the `watch` feature,
85//! import files are watched like the main file.
86//!
87//! # Events and write coalescing
88//!
89//! Lifecycle transitions are observable through the [`events`] module's
90//! event names on the root context's bus; listener failures are recorded,
91//! never propagated. Write-backs can be debounced via
92//! [`LoaderConfig::with_write_debounce`] or
93//! [`Loader::set_write_debounce`]: rapid successive writes coalesce into
94//! one physical write after the quiet window.
95//!
96//! # Dynamic library plugins
97//!
98//! With the `dynamic` feature, plugins can be compiled as `cdylib`
99//! libraries and resolved from a directory instead of being registered
100//! statically:
101//!
102//! ```rust,ignore
103//! let registry = PluginRegistry::new().with_dynamic_dirs(["./plugins"]);
104//! ```
105//!
106//! A plugin library exports its implementation through
107//! [`dynamic::export_plugin!`] and must be built by the exact same
108//! toolchain, target, panic strategy, and cordis-rs version as the loading
109//! process — the loader verifies a build fingerprint before accepting the
110//! library. Libraries are never unloaded within a process; reloading a
111//! changed library is the worker-restart HMR flow implemented by
112//! `cordis-cli`.
113//!
114//! # Not in scope yet
115//!
116//! Isolate/service migration is future work.
117
118// `deny` instead of `forbid` because the `dynamic` feature wraps
119// libloading's unsafe primitives; every unsafe operation lives in that one
120// module, item-scoped behind `#[allow(unsafe_code)]` with SAFETY notes
121// (the same pattern cordis-cli uses for dotenv).
122#![deny(unsafe_code)]
123#![warn(missing_docs)]
124
125#[cfg(feature = "dynamic")]
126pub mod dynamic;
127pub mod error;
128pub mod events;
129pub mod loader;
130pub mod registry;
131
132pub use cordis_group::Group;
133pub use cordis_include::{Document, Entry, EntryOptions, EntryTree, LoaderFile, Node};
134pub use error::{LoaderError, Result};
135pub use loader::{Loader, LoaderConfig, LoaderHandle};
136pub use registry::PluginRegistry;
137
138/// Lock a mutex tolerantly, treating a poisoned lock as unlocked.
139pub(crate) fn lock<T>(mutex: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
140 mutex.lock().unwrap_or_else(|error| error.into_inner())
141}