use crate::error::{LoaderError, Result};
use crate::lock;
use crate::registry::{PluginRegistry, WithInject};
use cordis::{
Config, Context, CordisError, EffectHandle, ErrorCode, EventOptions, Fiber, FiberState,
PluginHandle, Value,
};
use cordis_include::{Entry, EntryOptions, EntryTree, LoaderFile, Node, PluginResolver, TreeDiff};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Condvar, Mutex, Weak};
use std::thread::ThreadId;
use std::time::Duration;
#[derive(Clone, Default)]
pub struct LoaderConfig {
pub filename: PathBuf,
pub initial: Option<cordis_include::Document>,
pub document: Option<cordis_include::Document>,
pub registry: Option<PluginRegistry>,
pub write_debounce: Option<Duration>,
}
impl LoaderConfig {
pub fn new(filename: impl Into<PathBuf>) -> Self {
Self {
filename: filename.into(),
initial: None,
document: None,
registry: None,
write_debounce: None,
}
}
pub fn with_initial(mut self, initial: cordis_include::Document) -> Self {
self.initial = Some(initial);
self
}
pub fn with_document(mut self, document: cordis_include::Document) -> Self {
self.document = Some(document);
self
}
pub fn with_registry(mut self, registry: PluginRegistry) -> Self {
self.registry = Some(registry);
self
}
pub fn with_write_debounce(mut self, delay: Duration) -> Self {
self.write_debounce = Some(delay);
self
}
}
struct LoaderState {
entries: HashMap<u64, Entry>,
operating: u16,
last_error: Option<String>,
_keep_alive: Vec<EffectHandle>,
}
#[derive(Clone)]
pub struct Loader {
pub(crate) inner: Arc<LoaderInner>,
}
pub(crate) struct LoaderInner {
root: Context,
file: LoaderFile,
tree: EntryTree,
registry: Mutex<PluginRegistry>,
state: Mutex<LoaderState>,
operation: OperationLock,
document: Mutex<Option<cordis_include::Document>>,
imports: Mutex<HashMap<PathBuf, LoaderFile>>,
#[cfg(feature = "watch")]
watched: Mutex<HashSet<PathBuf>>,
#[cfg(feature = "watch")]
watchers: Mutex<Vec<cordis_include::FileWatcher>>,
write_debounce: Mutex<Option<Duration>>,
}
pub struct LoaderHandle {
inner: Weak<LoaderInner>,
}
impl LoaderHandle {
pub fn upgrade(&self) -> Option<Loader> {
self.inner.upgrade().map(|inner| Loader { inner })
}
}
impl Loader {
pub fn open(root: &Context, config: LoaderConfig) -> Result<Loader> {
let file = LoaderFile::open(&config.filename)?;
if config.document.is_none() && !file.path().exists() {
if let Some(initial) = &config.initial {
file.write(initial)?;
}
}
let mut imports = HashMap::new();
let mut errors = Vec::new();
let document = config.document;
let composed = match document.clone() {
Some(document) => compose_entries(
document.entries,
&file,
&mut imports,
&mut HashSet::new(),
&mut HashSet::new(),
&mut errors,
),
None => compose(
&file,
&mut imports,
&mut HashSet::new(),
&mut HashSet::new(),
&mut errors,
)?,
};
let inner = Arc::new(LoaderInner {
root: root.clone(),
file,
tree: EntryTree::new(),
registry: Mutex::new(config.registry.unwrap_or_default()),
state: Mutex::new(LoaderState {
entries: HashMap::new(),
operating: 0,
last_error: (!errors.is_empty()).then(|| errors.join("; ")),
_keep_alive: Vec::new(),
}),
operation: OperationLock::default(),
document: Mutex::new(document),
imports: Mutex::new(imports),
#[cfg(feature = "watch")]
watched: Mutex::new(HashSet::new()),
#[cfg(feature = "watch")]
watchers: Mutex::new(Vec::new()),
write_debounce: Mutex::new(config.write_debounce),
});
inner.tree.reconcile(composed)?;
let weak = Arc::downgrade(&inner);
let status = root.events().on(
"internal/status",
move |event| {
if let Some(inner) = weak.upgrade() {
handle_status(&inner, &event)?;
}
Ok(None)
},
EventOptions {
global: true,
..EventOptions::default()
},
)?;
let service = root.provide_arc(
"loader",
Arc::new(LoaderHandle {
inner: Arc::downgrade(&inner),
}),
)?;
lock(&inner.state)._keep_alive = vec![status, service];
let loader = Loader { inner };
loader.start_all();
Ok(loader)
}
pub fn context(&self) -> &Context {
&self.inner.root
}
pub fn tree(&self) -> &EntryTree {
&self.inner.tree
}
pub fn file(&self) -> &LoaderFile {
&self.inner.file
}
pub fn registry(&self) -> PluginRegistry {
lock(&self.inner.registry).clone()
}
pub fn register_plugin<P: cordis::Plugin>(&self, plugin: P) {
lock(&self.inner.registry).register_plugin(plugin);
}
pub fn register<F>(&self, name: impl Into<String>, factory: F)
where
F: Fn() -> PluginHandle + Send + Sync + 'static,
{
lock(&self.inner.registry).register(name, factory);
}
pub fn last_error(&self) -> Option<String> {
lock(&self.inner.state).last_error.clone()
}
pub fn set_write_debounce(&self, delay: Option<Duration>) {
*lock(&self.inner.write_debounce) = delay;
}
pub fn locate(&self, fiber: &Fiber) -> Option<Entry> {
let state = lock(&self.inner.state);
if let Some(uid) = fiber.uid() {
return state.entries.get(&uid).cloned();
}
state
.entries
.values()
.find(|entry| entry.fiber().is_some_and(|started| started.ptr_eq(fiber)))
.cloned()
}
fn start_all(&self) {
for entry in self.inner.tree.entries() {
if let Err(error) = start_entry(&self.inner, &entry) {
self.record_error(error);
}
}
}
pub fn reload(&self) -> Result<TreeDiff> {
let inner = &self.inner;
let _operation = inner.operation.guard();
let mut imports = HashMap::new();
let mut errors = Vec::new();
let composed = match lock(&inner.document).clone() {
Some(document) => compose_entries(
document.entries,
&inner.file,
&mut imports,
&mut HashSet::new(),
&mut HashSet::new(),
&mut errors,
),
None => match compose(
&inner.file,
&mut imports,
&mut HashSet::new(),
&mut HashSet::new(),
&mut errors,
) {
Ok(composed) => composed,
Err(error) => {
self.record_error(&error);
return Err(error.into());
}
},
};
let dirty = missing_id(&composed);
for error in errors {
self.record_error(LoaderError::Include(
cordis_include::IncludeError::Message { message: error },
));
}
let diff = reconcile(inner, composed, imports)?;
if dirty {
write_back(inner)?;
}
#[cfg(feature = "watch")]
self.arm_import_watchers();
Ok(diff)
}
pub fn recompose(&self, document: cordis_include::Document) -> Result<TreeDiff> {
let inner = &self.inner;
let _operation = inner.operation.guard();
let mut imports = HashMap::new();
let mut errors = Vec::new();
let composed = compose_entries(
document.entries.clone(),
&inner.file,
&mut imports,
&mut HashSet::new(),
&mut HashSet::new(),
&mut errors,
);
for error in errors {
self.record_error(LoaderError::Include(
cordis_include::IncludeError::Message { message: error },
));
}
*lock(&inner.document) = Some(document);
let diff = reconcile(inner, composed, imports)?;
#[cfg(feature = "watch")]
self.arm_import_watchers();
Ok(diff)
}
pub fn update_config(&self, id: &str, config: Node) -> Result<()> {
let inner = &self.inner;
let _operation = inner.operation.guard();
let entry = inner.tree.resolve(id).ok_or_else(|| {
LoaderError::Include(cordis_include::IncludeError::EntryNotFound { id: id.to_owned() })
})?;
if let Some(fiber) = entry.fiber() {
fiber.update_value(Config::new(config.clone()))?;
}
let mut options = entry_options_with_children(&entry);
options.config = Some(config.clone());
inner
.tree
.reconcile_entry(&entry.path(), options, None, None)?;
write_back(inner)?;
emit(
inner,
crate::events::CONFIG_UPDATE,
vec![Value::new(entry), Value::new(config)],
);
Ok(())
}
pub fn dispose(&self) -> Result<()> {
let inner = &self.inner;
let _operation = inner.operation.guard();
for entry in inner.tree.top_level() {
if let Err(error) = stop_entry(inner, &entry) {
self.record_error(error);
}
}
#[cfg(feature = "watch")]
{
lock(&inner.watched).clear();
lock(&inner.watchers).clear();
}
let keep_alive = std::mem::take(&mut lock(&inner.state)._keep_alive);
for effect in &keep_alive {
if let Err(error) = effect.dispose() {
self.record_error(LoaderError::Cordis(error));
}
}
Ok(())
}
#[cfg(feature = "watch")]
pub fn watch(&self) -> Result<cordis_include::FileWatcher> {
let loader = self.clone();
let watcher = self
.inner
.file
.watch(move || {
if let Err(error) = loader.reload() {
loader.record_error(error);
}
})
.map_err(LoaderError::Include)?;
let main_path = std::fs::canonicalize(self.inner.file.path())
.unwrap_or_else(|_| self.inner.file.path().to_path_buf());
lock(&self.inner.watched).insert(main_path);
self.arm_import_watchers();
Ok(watcher)
}
#[cfg(feature = "watch")]
fn arm_import_watchers(&self) {
for (path, file) in lock(&self.inner.imports).clone() {
if lock(&self.inner.watched).contains(&path) {
continue;
}
let loader = self.clone();
match file.watch(move || {
if let Err(error) = loader.reload() {
loader.record_error(error);
}
}) {
Ok(watcher) => {
lock(&self.inner.watched).insert(path);
lock(&self.inner.watchers).push(watcher);
}
Err(error) => self.record_error(LoaderError::Include(error)),
}
}
}
fn record_error(&self, error: impl std::fmt::Display) {
record_error(&self.inner, &error);
}
}
struct OperatingGuard<'a> {
state: &'a Mutex<LoaderState>,
}
impl<'a> OperatingGuard<'a> {
fn new(state: &'a Mutex<LoaderState>) -> Self {
lock(state).operating += 1;
Self { state }
}
}
impl Drop for OperatingGuard<'_> {
fn drop(&mut self) {
let mut state = lock(self.state);
state.operating = state.operating.saturating_sub(1);
}
}
#[derive(Default)]
struct OperationLock {
state: Mutex<OperationState>,
released: Condvar,
}
#[derive(Default)]
struct OperationState {
owner: Option<ThreadId>,
depth: usize,
}
impl OperationLock {
fn guard(&self) -> OperationGuard<'_> {
let current = std::thread::current().id();
let mut state = lock(&self.state);
loop {
if state.owner.is_none_or(|owner| owner == current) {
state.owner = Some(current);
state.depth += 1;
return OperationGuard { lock: self };
}
let guard = self
.released
.wait(state)
.unwrap_or_else(|error| error.into_inner());
state = guard;
}
}
}
struct OperationGuard<'a> {
lock: &'a OperationLock,
}
impl Drop for OperationGuard<'_> {
fn drop(&mut self) {
let mut state = lock(&self.lock.state);
state.depth = state.depth.saturating_sub(1);
if state.depth == 0 {
state.owner = None;
drop(state);
self.lock.released.notify_all();
}
}
}
fn emit(inner: &LoaderInner, name: &str, args: Vec<Value>) {
if let Err(error) = inner.root.events().emit(name, args) {
lock(&inner.state).last_error = Some(format!("{name} listener failed: {error}"));
}
}
fn record_error(inner: &LoaderInner, error: &dyn std::fmt::Display) {
lock(&inner.state).last_error = Some(error.to_string());
}
fn reconcile(
inner: &LoaderInner,
composed: Vec<EntryOptions>,
imports: HashMap<PathBuf, LoaderFile>,
) -> Result<TreeDiff> {
let diff = inner.tree.reconcile(composed)?;
*lock(&inner.imports) = imports;
for removed in &diff.removed {
if let Err(error) = stop_entry(inner, &removed.entry) {
record_error(inner, &error);
}
}
for entry in &diff.moved {
if let Err(error) = stop_entry(inner, entry) {
record_error(inner, &error);
}
}
for entry in &diff.redefined {
if let Err(error) = stop_entry(inner, entry) {
record_error(inner, &error);
}
}
for entry in &diff.updated {
if let Err(error) = patch_entry(inner, entry) {
record_error(inner, &error);
}
}
for entry in &diff.created {
if let Err(error) = start_entry(inner, entry) {
record_error(inner, &error);
}
}
let mut restarts: Vec<&Entry> = diff
.moved
.iter()
.chain(&diff.redefined)
.chain(diff.updated.iter().filter(|entry| entry.fiber().is_none()))
.collect();
restarts.sort_by_key(|entry| entry_depth(entry));
for entry in restarts {
if let Err(error) = start_subtree(inner, entry) {
record_error(inner, &error);
}
}
Ok(diff)
}
fn start_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
if entry.fiber().is_some() {
return Ok(());
}
if !entry.resolved_enabled()? {
return Ok(());
}
let name = entry.name();
let handle: PluginHandle = lock(&inner.registry)
.resolve(&name)
.map_err(LoaderError::Cordis)?;
let inject = entry.options().inject;
let handle = WithInject::wrap(handle, inject);
let config = entry.resolved_config()?.unwrap_or(Node::Null);
let parent_ctx = entry
.parent()
.and_then(|parent| parent.fiber())
.and_then(|fiber| fiber.context())
.unwrap_or_else(|| inner.root.clone());
let fiber = parent_ctx.plugin(handle, config);
let Some(uid) = fiber.uid() else {
return Err(LoaderError::Cordis(
fiber
.error()
.unwrap_or_else(|| CordisError::new(ErrorCode::InactiveEffect)),
));
};
entry.set_fiber(Some(fiber.clone()));
lock(&inner.state).entries.insert(uid, entry.clone());
emit(
inner,
crate::events::ENTRY_INIT,
vec![Value::new(entry.clone())],
);
Ok(())
}
fn stop_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
for child in entry.children() {
stop_entry(inner, &child)?;
}
let Some(fiber) = entry.fiber() else {
return Ok(());
};
entry.set_fiber(None);
if let Some(uid) = fiber.uid() {
lock(&inner.state).entries.remove(&uid);
}
let _guard = OperatingGuard::new(&inner.state);
fiber.dispose().map_err(LoaderError::Cordis)
}
fn patch_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
if !entry.resolved_enabled()? {
return stop_entry(inner, entry);
}
let Some(fiber) = entry.fiber() else {
return Ok(());
};
let new_config = entry.resolved_config()?.unwrap_or(Node::Null);
let current = fiber
.config()
.downcast::<Node>()
.ok()
.map(|node| (*node).clone());
if current.as_ref() != Some(&new_config) {
emit(
inner,
crate::events::BEFORE_PATCH,
vec![Value::new(entry.clone())],
);
if let Err(error) = fiber.update_value(Config::new(new_config)) {
if let Some(old_config) = current {
let mut options = entry_options_with_children(entry);
options.config = Some(old_config);
if let Err(revert) = inner
.tree
.reconcile_entry(&entry.path(), options, None, None)
{
lock(&inner.state).last_error = Some(format!(
"failed to roll back config of {}: {revert}",
entry.path()
));
}
}
return Err(LoaderError::Cordis(error));
}
emit(
inner,
crate::events::AFTER_PATCH,
vec![Value::new(entry.clone())],
);
}
Ok(())
}
fn start_subtree(inner: &LoaderInner, entry: &Entry) -> Result<()> {
start_entry(inner, entry)?;
for child in entry.children() {
start_subtree(inner, &child)?;
}
Ok(())
}
fn entry_depth(entry: &Entry) -> usize {
let mut depth = 0;
let mut current = entry.clone();
while let Some(parent) = current.parent() {
depth += 1;
current = parent;
}
depth
}
fn entry_options_with_children(entry: &Entry) -> EntryOptions {
let mut options = entry.options();
options.group = entry
.children()
.iter()
.map(entry_options_with_children)
.collect();
options
}
fn write_back(inner: &LoaderInner) -> Result<()> {
let mut jobs: Vec<(LoaderFile, Vec<EntryOptions>)> = vec![(
inner.file.clone(),
inner
.tree
.top_level()
.iter()
.map(to_stripped_options)
.collect(),
)];
for entry in inner.tree.entries() {
if entry.options().import_url().is_some() {
if let Some(file) = lock(&inner.imports).get(&import_canonical(inner, &entry)) {
let children = entry.children().iter().map(to_stripped_options).collect();
jobs.push((file.clone(), children));
}
}
}
let debounce = *lock(&inner.write_debounce);
for (file, entries) in jobs {
let mut document = file.read()?;
document.entries = entries;
match debounce {
Some(delay) => file.write_deferred(document, delay),
None => file.write(&document)?,
}
}
Ok(())
}
fn to_stripped_options(entry: &Entry) -> EntryOptions {
fn strip(options: &mut EntryOptions) {
if options.import_url().is_some() {
options.group.clear();
return;
}
options.group.retain(|child| child.import_url().is_none());
for child in &mut options.group {
strip(child);
}
}
let mut options = entry_options_with_children(entry);
strip(&mut options);
options
}
fn import_path(base_file: &LoaderFile, url: &str) -> PathBuf {
let direct = Path::new(url);
if direct.is_absolute() {
return direct.to_path_buf();
}
match base_file.path().parent() {
Some(parent) => parent.join(url),
None => direct.to_path_buf(),
}
}
fn import_canonical(inner: &LoaderInner, entry: &Entry) -> PathBuf {
let url = entry.options().import_url().unwrap_or_default().to_owned();
let path = import_path(&inner.file, &url);
std::fs::canonicalize(&path).unwrap_or(path)
}
fn missing_id(entries: &[EntryOptions]) -> bool {
entries
.iter()
.any(|options| options.id.is_none() || missing_id(&options.group))
}
fn compose(
file: &LoaderFile,
imports: &mut HashMap<PathBuf, LoaderFile>,
active: &mut HashSet<PathBuf>,
mounted: &mut HashSet<PathBuf>,
errors: &mut Vec<String>,
) -> cordis_include::Result<Vec<EntryOptions>> {
let document = file.read()?;
Ok(compose_entries(
document.entries,
file,
imports,
active,
mounted,
errors,
))
}
fn compose_entries(
entries: Vec<EntryOptions>,
base: &LoaderFile,
imports: &mut HashMap<PathBuf, LoaderFile>,
active: &mut HashSet<PathBuf>,
mounted: &mut HashSet<PathBuf>,
errors: &mut Vec<String>,
) -> Vec<EntryOptions> {
let mut composed = Vec::with_capacity(entries.len());
for mut options in entries {
if let Some(url) = options.import_url().map(str::to_owned) {
let path = import_path(base, &url);
let canonical = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
if !active.insert(canonical.clone()) {
errors.push(format!("import cycle detected at {}", path.display()));
continue;
}
if !mounted.insert(canonical.clone()) {
errors.push(format!(
"duplicate import: {} is already mounted elsewhere; \
the import graph must be a tree",
path.display()
));
active.remove(&canonical);
continue;
}
match LoaderFile::open(&path) {
Ok(sub_file) => {
match compose(&sub_file, imports, active, mounted, errors) {
Ok(sub_entries) => {
options.group = sub_entries;
}
Err(error) => {
errors.push(format!(
"cannot read import {}: {error}",
sub_file.path().display()
));
options.group.clear();
}
}
imports.insert(canonical.clone(), sub_file);
}
Err(error) => errors.push(format!(
"cannot open import {} ({}: {error})",
path.display(),
base.path().display()
)),
}
active.remove(&canonical);
}
composed.push(options);
}
composed
}
fn handle_status(inner: &Arc<LoaderInner>, event: &cordis::Event) -> cordis::EventResult {
let Some(fiber) = event.arg::<Fiber>(0).ok().flatten() else {
return Ok(None);
};
if fiber.state() != FiberState::Disposed {
return Ok(None);
}
if lock(&inner.state).operating > 0 {
return Ok(None);
}
let Some(entry) = lock(&inner.state)
.entries
.values()
.find(|entry| entry.fiber().is_some_and(|started| started.ptr_eq(&fiber)))
.cloned()
else {
return Ok(None);
};
let deferred = std::thread::Builder::new()
.name("cordis-self-dispose".to_owned())
.spawn({
let inner = Arc::clone(inner);
let entry = entry.clone();
move || {
let _operation = inner.operation.guard();
if let Err(error) = persist_self_dispose(&inner, &entry) {
lock(&inner.state).last_error = Some(error.to_string());
}
}
});
match deferred {
Ok(_join) => {}
Err(_) => {
let _operation = inner.operation.guard();
if let Err(error) = persist_self_dispose(inner, &entry) {
lock(&inner.state).last_error = Some(error.to_string());
}
}
}
Ok(None)
}
fn persist_self_dispose(inner: &LoaderInner, entry: &Entry) -> Result<()> {
{
let mut state = lock(&inner.state);
let key = state
.entries
.iter()
.find(|(_, mapped)| Entry::ptr_eq(mapped, entry))
.map(|(uid, _)| *uid);
if let Some(uid) = key {
state.entries.remove(&uid);
}
}
entry.set_fiber(None);
let mut options = entry_options_with_children(entry);
options.disabled = cordis_include::Disabled::Flag(true);
inner
.tree
.reconcile_entry(&entry.path(), options, None, None)?;
write_back(inner)?;
emit(
inner,
crate::events::PARTIAL_DISPOSE,
vec![Value::new(entry.clone())],
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use cordis::{Inject, PluginOutput, plugin_sync};
#[test]
fn rejected_start_leaves_the_entry_retryable() {
let path = std::env::temp_dir().join(format!(
"cordis-loader-rejected-start-{}-{}.yml",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_nanos() as u64)
.unwrap_or(0)
));
let _ = std::fs::remove_file(&path);
let mut registry = PluginRegistry::new();
registry.register("worker", || {
plugin_sync::<Node, _>("worker", Inject::default(), |_, _| Ok(PluginOutput::none()))
});
let root = Context::new();
let loader = Loader::open(
&root,
LoaderConfig::new(&path)
.with_registry(registry)
.with_initial(cordis_include::Document::with_entries(vec![
EntryOptions::new("group")
.with_id("g1")
.with_group(vec![EntryOptions::new("worker").with_id("c1")]),
])),
)
.unwrap();
let inner = &loader.inner;
let group = inner.tree.resolve("g1").unwrap();
let child = inner.tree.resolve("g1:c1").unwrap();
assert!(group.fiber().is_some() && child.fiber().is_some());
{
let _operating = OperatingGuard::new(&inner.state);
group.fiber().unwrap().dispose().unwrap();
}
child.set_fiber(None);
let result = start_entry(inner, &child);
assert!(result.is_err(), "the registry rejection must surface");
assert!(child.fiber().is_none(), "no rejected fiber recorded");
assert!(start_entry(inner, &child).is_err());
assert!(child.fiber().is_none());
drop(loader);
let _ = std::fs::remove_file(&path);
}
}