1use 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#[derive(Clone, Default)]
19pub struct LoaderConfig {
20 pub filename: PathBuf,
22 pub initial: Option<cordis_include::Document>,
24 pub document: Option<cordis_include::Document>,
30 pub registry: Option<PluginRegistry>,
33 pub write_debounce: Option<Duration>,
36}
37
38impl LoaderConfig {
39 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 pub fn with_initial(mut self, initial: cordis_include::Document) -> Self {
52 self.initial = Some(initial);
53 self
54 }
55
56 pub fn with_document(mut self, document: cordis_include::Document) -> Self {
67 self.document = Some(document);
68 self
69 }
70
71 pub fn with_registry(mut self, registry: PluginRegistry) -> Self {
73 self.registry = Some(registry);
74 self
75 }
76
77 pub fn with_write_debounce(mut self, delay: Duration) -> Self {
80 self.write_debounce = Some(delay);
81 self
82 }
83}
84
85struct LoaderState {
87 entries: HashMap<u64, Entry>,
89 operating: u16,
92 last_error: Option<String>,
94 _keep_alive: Vec<EffectHandle>,
96}
97
98#[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 operation: OperationLock,
120 document: Mutex<Option<cordis_include::Document>>,
126 imports: Mutex<HashMap<PathBuf, LoaderFile>>,
128 #[cfg(feature = "watch")]
130 watched: Mutex<HashSet<PathBuf>>,
131 #[cfg(feature = "watch")]
133 watchers: Mutex<Vec<cordis_include::FileWatcher>>,
134 write_debounce: Mutex<Option<Duration>>,
136}
137
138pub struct LoaderHandle {
143 inner: Weak<LoaderInner>,
144}
145
146impl LoaderHandle {
147 pub fn upgrade(&self) -> Option<Loader> {
149 self.inner.upgrade().map(|inner| Loader { inner })
150 }
151}
152
153impl Loader {
154 pub fn open(root: &Context, config: LoaderConfig) -> Result<Loader> {
161 let file = LoaderFile::open(&config.filename)?;
162 if config.document.is_none() && !file.path().exists() {
166 if let Some(initial) = &config.initial {
167 file.write(initial)?;
168 }
169 }
170 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 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 pub fn context(&self) -> &Context {
248 &self.inner.root
249 }
250
251 pub fn tree(&self) -> &EntryTree {
253 &self.inner.tree
254 }
255
256 pub fn file(&self) -> &LoaderFile {
258 &self.inner.file
259 }
260
261 pub fn registry(&self) -> PluginRegistry {
265 lock(&self.inner.registry).clone()
266 }
267
268 pub fn register_plugin<P: cordis::Plugin>(&self, plugin: P) {
271 lock(&self.inner.registry).register_plugin(plugin);
272 }
273
274 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 pub fn last_error(&self) -> Option<String> {
284 lock(&self.inner.state).last_error.clone()
285 }
286
287 pub fn set_write_debounce(&self, delay: Option<Duration>) {
290 *lock(&self.inner.write_debounce) = delay;
291 }
292
293 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 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 pub fn reload(&self) -> Result<TreeDiff> {
329 let inner = &self.inner;
330 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 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 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 if dirty {
376 write_back(inner)?;
377 }
378 #[cfg(feature = "watch")]
379 self.arm_import_watchers();
380 Ok(diff)
381 }
382
383 pub fn update(&self, document: cordis_include::Document) -> Result<TreeDiff> {
397 let inner = &self.inner;
398 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 pub fn update_config(&self, id: &str, config: Node) -> Result<()> {
426 let inner = &self.inner;
427 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 pub fn dispose(&self) -> Result<()> {
456 let inner = &self.inner;
457 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 #[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 #[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
529struct 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#[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 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
605fn 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
613fn record_error(inner: &LoaderInner, error: &dyn std::fmt::Display) {
615 lock(&inner.state).last_error = Some(error.to_string());
616}
617
618fn 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 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
676fn 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 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
723fn 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
740fn 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 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
794fn 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
804fn 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
816fn 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
828fn 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
861fn to_stripped_options(entry: &Entry) -> EntryOptions {
865 fn strip(options: &mut EntryOptions) {
866 if options.import_url().is_some() {
867 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
881fn 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
894fn 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
901fn 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
929fn 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 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 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 active.remove(&canonical);
997 }
998 composed.push(options);
999 }
1000 (composed, dirty)
1001}
1002
1003fn 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 let inner = Arc::clone(inner);
1038 let entry = entry.clone();
1039 move || {
1040 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 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
1062fn 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 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 #[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 {
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 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}