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<()> {
678 if !entry.enabled() || entry.fiber().is_some() {
679 return Ok(());
680 }
681 let name = entry.name();
682 let handle: PluginHandle = lock(&inner.registry)
683 .resolve(&name)
684 .map_err(LoaderError::Cordis)?;
685 let inject = entry.options().inject;
686 let handle = WithInject::wrap(handle, inject);
687 let config = entry.resolved_config()?.unwrap_or(Node::Null);
688 let parent_ctx = entry
689 .parent()
690 .and_then(|parent| parent.fiber())
691 .and_then(|fiber| fiber.context())
692 .unwrap_or_else(|| inner.root.clone());
693 let fiber = parent_ctx.plugin(handle, config);
694 let Some(uid) = fiber.uid() else {
695 return Err(LoaderError::Cordis(
702 fiber
703 .error()
704 .unwrap_or_else(|| CordisError::new(ErrorCode::InactiveEffect)),
705 ));
706 };
707 entry.set_fiber(Some(fiber.clone()));
708 lock(&inner.state).entries.insert(uid, entry.clone());
709 emit(
710 inner,
711 crate::events::ENTRY_INIT,
712 vec![Value::new(entry.clone())],
713 );
714 Ok(())
715}
716
717fn stop_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
720 for child in entry.children() {
721 stop_entry(inner, &child)?;
722 }
723 let Some(fiber) = entry.fiber() else {
724 return Ok(());
725 };
726 entry.set_fiber(None);
727 if let Some(uid) = fiber.uid() {
728 lock(&inner.state).entries.remove(&uid);
729 }
730 let _guard = OperatingGuard::new(&inner.state);
731 fiber.dispose().map_err(LoaderError::Cordis)
732}
733
734fn patch_entry(inner: &LoaderInner, entry: &Entry) -> Result<()> {
739 if !entry.enabled() {
740 return stop_entry(inner, entry);
741 }
742 let Some(fiber) = entry.fiber() else {
743 return Ok(());
744 };
745 let new_config = entry.resolved_config()?.unwrap_or(Node::Null);
746 let current = fiber
747 .config()
748 .downcast::<Node>()
749 .ok()
750 .map(|node| (*node).clone());
751 if current.as_ref() != Some(&new_config) {
752 emit(
753 inner,
754 crate::events::BEFORE_PATCH,
755 vec![Value::new(entry.clone())],
756 );
757 if let Err(error) = fiber.update_value(Config::new(new_config)) {
758 if let Some(old_config) = current {
765 let mut options = entry_options_with_children(entry);
766 options.config = Some(old_config);
767 if let Err(revert) = inner.tree.update_entry(&entry.path(), options, None, None) {
768 lock(&inner.state).last_error = Some(format!(
769 "failed to roll back config of {}: {revert}",
770 entry.path()
771 ));
772 }
773 }
774 return Err(LoaderError::Cordis(error));
775 }
776 emit(
777 inner,
778 crate::events::AFTER_PATCH,
779 vec![Value::new(entry.clone())],
780 );
781 }
782 Ok(())
783}
784
785fn start_subtree(inner: &LoaderInner, entry: &Entry) -> Result<()> {
788 start_entry(inner, entry)?;
789 for child in entry.children() {
790 start_subtree(inner, &child)?;
791 }
792 Ok(())
793}
794
795fn entry_depth(entry: &Entry) -> usize {
798 let mut depth = 0;
799 let mut current = entry.clone();
800 while let Some(parent) = current.parent() {
801 depth += 1;
802 current = parent;
803 }
804 depth
805}
806
807fn entry_options_with_children(entry: &Entry) -> EntryOptions {
810 let mut options = entry.options();
811 options.group = entry
812 .children()
813 .iter()
814 .map(entry_options_with_children)
815 .collect();
816 options
817}
818
819fn write_back(inner: &LoaderInner) -> Result<()> {
823 let mut jobs: Vec<(LoaderFile, Vec<EntryOptions>)> = vec![(
824 inner.file.clone(),
825 inner
826 .tree
827 .top_level()
828 .iter()
829 .map(to_stripped_options)
830 .collect(),
831 )];
832 for entry in inner.tree.entries() {
833 if entry.options().import_url().is_some() {
834 if let Some(file) = lock(&inner.imports).get(&import_canonical(inner, &entry)) {
835 let children = entry.children().iter().map(to_stripped_options).collect();
836 jobs.push((file.clone(), children));
837 }
838 }
839 }
840 let debounce = *lock(&inner.write_debounce);
841 for (file, entries) in jobs {
842 let mut document = file.read()?;
843 document.entries = entries;
844 match debounce {
845 Some(delay) => file.write_deferred(document, delay),
846 None => file.write(&document)?,
847 }
848 }
849 Ok(())
850}
851
852fn to_stripped_options(entry: &Entry) -> EntryOptions {
856 fn strip(options: &mut EntryOptions) {
857 if options.import_url().is_some() {
858 options.group.clear();
860 return;
861 }
862 options.group.retain(|child| child.import_url().is_none());
863 for child in &mut options.group {
864 strip(child);
865 }
866 }
867 let mut options = entry_options_with_children(entry);
868 strip(&mut options);
869 options
870}
871
872fn import_path(base_file: &LoaderFile, url: &str) -> PathBuf {
875 let direct = Path::new(url);
876 if direct.is_absolute() {
877 return direct.to_path_buf();
878 }
879 match base_file.path().parent() {
880 Some(parent) => parent.join(url),
881 None => direct.to_path_buf(),
882 }
883}
884
885fn import_canonical(inner: &LoaderInner, entry: &Entry) -> PathBuf {
887 let url = entry.options().import_url().unwrap_or_default().to_owned();
888 let path = import_path(&inner.file, &url);
889 std::fs::canonicalize(&path).unwrap_or(path)
890}
891
892fn compose(
903 file: &LoaderFile,
904 imports: &mut HashMap<PathBuf, LoaderFile>,
905 active: &mut HashSet<PathBuf>,
906 mounted: &mut HashSet<PathBuf>,
907 errors: &mut Vec<String>,
908) -> cordis_include::Result<(Vec<EntryOptions>, bool)> {
909 let document = file.read()?;
910 Ok(compose_entries(
911 document.entries,
912 file,
913 imports,
914 active,
915 mounted,
916 errors,
917 ))
918}
919
920fn compose_entries(
932 entries: Vec<EntryOptions>,
933 base: &LoaderFile,
934 imports: &mut HashMap<PathBuf, LoaderFile>,
935 active: &mut HashSet<PathBuf>,
936 mounted: &mut HashSet<PathBuf>,
937 errors: &mut Vec<String>,
938) -> (Vec<EntryOptions>, bool) {
939 let mut composed = Vec::with_capacity(entries.len());
940 let mut dirty = entries.iter().any(|options| options.id.is_none());
941 for mut options in entries {
942 if let Some(url) = options.import_url().map(str::to_owned) {
943 let path = import_path(base, &url);
944 let canonical = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
945 if !active.insert(canonical.clone()) {
946 errors.push(format!("import cycle detected at {}", path.display()));
947 continue;
950 }
951 if !mounted.insert(canonical.clone()) {
952 errors.push(format!(
953 "duplicate import: {} is already mounted elsewhere; \
954 the import graph must be a tree",
955 path.display()
956 ));
957 active.remove(&canonical);
958 continue;
959 }
960 match LoaderFile::open(&path) {
961 Ok(sub_file) => {
962 match compose(&sub_file, imports, active, mounted, errors) {
963 Ok((sub_entries, sub_dirty)) => {
964 dirty |= sub_dirty;
965 options.group = sub_entries;
966 }
967 Err(error) => {
968 errors.push(format!(
969 "cannot read import {}: {error}",
970 sub_file.path().display()
971 ));
972 options.group.clear();
975 }
976 }
977 imports.insert(canonical.clone(), sub_file);
978 }
979 Err(error) => errors.push(format!(
980 "cannot open import {} ({}: {error})",
981 path.display(),
982 base.path().display()
983 )),
984 }
985 active.remove(&canonical);
988 }
989 composed.push(options);
990 }
991 (composed, dirty)
992}
993
994fn handle_status(inner: &Arc<LoaderInner>, event: &cordis::Event) -> cordis::EventResult {
1006 let Some(fiber) = event.arg::<Fiber>(0).ok().flatten() else {
1007 return Ok(None);
1008 };
1009 if fiber.state() != FiberState::Disposed {
1010 return Ok(None);
1011 }
1012 if lock(&inner.state).operating > 0 {
1013 return Ok(None);
1014 }
1015 let Some(entry) = lock(&inner.state)
1016 .entries
1017 .values()
1018 .find(|entry| entry.fiber().is_some_and(|started| started.ptr_eq(&fiber)))
1019 .cloned()
1020 else {
1021 return Ok(None);
1022 };
1023 let deferred = std::thread::Builder::new()
1024 .name("cordis-self-dispose".to_owned())
1025 .spawn({
1026 let inner = Arc::clone(inner);
1029 let entry = entry.clone();
1030 move || {
1031 let _operation = inner.operation.guard();
1034 if let Err(error) = persist_self_dispose(&inner, &entry) {
1035 lock(&inner.state).last_error = Some(error.to_string());
1036 }
1037 }
1038 });
1039 match deferred {
1040 Ok(_join) => {}
1041 Err(_) => {
1042 let _operation = inner.operation.guard();
1045 if let Err(error) = persist_self_dispose(inner, &entry) {
1046 lock(&inner.state).last_error = Some(error.to_string());
1047 }
1048 }
1049 }
1050 Ok(None)
1051}
1052
1053fn persist_self_dispose(inner: &LoaderInner, entry: &Entry) -> Result<()> {
1055 {
1056 let mut state = lock(&inner.state);
1057 let key = state
1058 .entries
1059 .iter()
1060 .find(|(_, mapped)| Entry::ptr_eq(mapped, entry))
1061 .map(|(uid, _)| *uid);
1062 if let Some(uid) = key {
1063 state.entries.remove(&uid);
1064 }
1065 }
1066 entry.set_fiber(None);
1067 let mut options = entry_options_with_children(entry);
1068 options.disabled = true;
1069 inner
1070 .tree
1071 .update_entry(&entry.path(), options, None, None)?;
1072 write_back(inner)?;
1073 emit(
1074 inner,
1075 crate::events::PARTIAL_DISPOSE,
1076 vec![Value::new(entry.clone())],
1077 );
1078 Ok(())
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 use super::*;
1084 use cordis::{Inject, PluginOutput, plugin_sync};
1085
1086 #[test]
1093 fn rejected_start_leaves_the_entry_retryable() {
1094 let path = std::env::temp_dir().join(format!(
1095 "cordis-loader-rejected-start-{}-{}.yml",
1096 std::process::id(),
1097 std::time::SystemTime::now()
1098 .duration_since(std::time::UNIX_EPOCH)
1099 .map(|elapsed| elapsed.as_nanos() as u64)
1100 .unwrap_or(0)
1101 ));
1102 let _ = std::fs::remove_file(&path);
1103 let mut registry = PluginRegistry::new();
1104 registry.register("worker", || {
1105 plugin_sync::<Node, _>("worker", Inject::default(), |_, _| Ok(PluginOutput::none()))
1106 });
1107 let root = Context::new();
1108 let loader = Loader::open(
1109 &root,
1110 LoaderConfig::new(&path)
1111 .with_registry(registry)
1112 .with_initial(cordis_include::Document::with_entries(vec![
1113 EntryOptions::new("group")
1114 .with_id("g1")
1115 .with_group(vec![EntryOptions::new("worker").with_id("c1")]),
1116 ])),
1117 )
1118 .unwrap();
1119 let inner = &loader.inner;
1120 let group = inner.tree.resolve("g1").unwrap();
1121 let child = inner.tree.resolve("g1:c1").unwrap();
1122 assert!(group.fiber().is_some() && child.fiber().is_some());
1123
1124 {
1127 let _operating = OperatingGuard::new(&inner.state);
1128 group.fiber().unwrap().dispose().unwrap();
1129 }
1130 child.set_fiber(None);
1131
1132 let result = start_entry(inner, &child);
1133 assert!(result.is_err(), "the registry rejection must surface");
1134 assert!(child.fiber().is_none(), "no rejected fiber recorded");
1135
1136 assert!(start_entry(inner, &child).is_err());
1139 assert!(child.fiber().is_none());
1140
1141 drop(loader);
1142 let _ = std::fs::remove_file(&path);
1143 }
1144}