1use std::any::TypeId;
21use std::collections::{BTreeSet, HashMap, HashSet};
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
24use std::sync::Arc;
25
26#[derive(Clone, Default)]
46pub struct LoaderOps {
47 inner: Arc<LoaderOpsInner>,
48}
49
50#[derive(Default)]
51struct LoaderOpsInner {
52 in_loader_window: AtomicBool,
54 apply_counts: std::sync::Mutex<HashMap<String, u64>>,
56 persistence: std::sync::Mutex<Option<Arc<SelfKillPersistence>>>,
58 persisted_disabled: std::sync::Mutex<BTreeSet<String>>,
61}
62
63impl Service for LoaderOps {}
64
65impl LoaderOps {
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 fn enter_loader_window(&self) -> LoaderWindowGuard {
71 self.inner.in_loader_window.store(true, Ordering::SeqCst);
72 LoaderWindowGuard(self.inner.clone())
73 }
74
75 fn in_loader_window(&self) -> bool {
76 self.inner.in_loader_window.load(Ordering::SeqCst)
77 }
78
79 fn record_apply(&self, id: &str) {
80 *self
81 .inner
82 .apply_counts
83 .lock()
84 .unwrap_or_else(std::sync::PoisonError::into_inner)
85 .entry(id.to_string())
86 .or_insert(0) += 1;
87 }
88
89 pub fn apply_count(&self, id: &str) -> u64 {
91 self.inner
92 .apply_counts
93 .lock()
94 .unwrap_or_else(std::sync::PoisonError::into_inner)
95 .get(id)
96 .copied()
97 .unwrap_or(0)
98 }
99
100 pub fn enable_self_kill_persistence(&self, path: PathBuf, toon_format: bool) {
102 let mut sink = self
103 .inner
104 .persistence
105 .lock()
106 .unwrap_or_else(std::sync::PoisonError::into_inner);
107 *sink = Some(Arc::new(SelfKillPersistence { path, toon_format }));
108 drop(sink);
109 self.inner
111 .persisted_disabled
112 .lock()
113 .unwrap_or_else(std::sync::PoisonError::into_inner)
114 .clear();
115 }
116
117 fn self_kill_persistence(&self) -> Option<Arc<SelfKillPersistence>> {
118 self.inner
119 .persistence
120 .lock()
121 .unwrap_or_else(std::sync::PoisonError::into_inner)
122 .clone()
123 }
124
125 fn persist_self_kill(&self, id: &str) {
127 if !self
128 .inner
129 .persisted_disabled
130 .lock()
131 .unwrap_or_else(std::sync::PoisonError::into_inner)
132 .insert(id.to_string())
133 {
134 return;
135 }
136 let Some(persistence) = self.self_kill_persistence() else {
137 tracing::warn!(entry_id = %id,
138 "Loader: plugin disposed itself outside a loader window but no entries \
139 program is configured; restart would resurrect it");
140 return;
141 };
142 match persistence.persist_disabled(id) {
143 Ok(()) => tracing::warn!(entry_id = %id,
144 "Loader: plugin disposed itself outside a loader window; persisted disabled=true"),
145 Err(e) => {
146 self.inner
148 .persisted_disabled
149 .lock()
150 .unwrap_or_else(std::sync::PoisonError::into_inner)
151 .remove(id);
152 tracing::error!(entry_id = %id, error = %e,
153 "Loader: failed to persist disabled=true for self-disposed entry");
154 }
155 }
156 }
157}
158
159struct LoaderWindowGuard(Arc<LoaderOpsInner>);
164
165impl Drop for LoaderWindowGuard {
166 fn drop(&mut self) {
167 self.0.in_loader_window.store(false, Ordering::SeqCst);
168 }
169}
170
171struct SelfKillPersistence {
175 path: PathBuf,
176 toon_format: bool,
177}
178
179impl SelfKillPersistence {
180 fn persist_disabled(&self, id: &str) -> Result<(), CordisError> {
181 let mut tree = if self.toon_format {
182 Loader::load_from_file(&self.path)
183 } else {
184 EntryTree::load_from_json_path(&self.path)
185 }?;
186 let Some(entry) = tree.0.iter_mut().find(|e| e.id == id) else {
187 return Ok(()); };
189 if entry.disabled {
190 return Ok(()); }
192 entry.disabled = true;
193 if self.toon_format {
194 tree.save_to_toml_file(&self.path)
195 } else {
196 tree.save_to_file(
197 self.path
198 .to_str()
199 .ok_or_else(|| CordisError::Configuration("non-utf8 entries path".into()))?,
200 )
201 }
202 }
203}
204
205static SAVE_TMP_NONCE: AtomicU64 = AtomicU64::new(0);
209
210fn next_save_nonce() -> u64 {
211 SAVE_TMP_NONCE.fetch_add(1, Ordering::Relaxed)
212}
213
214fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), CordisError> {
222 if let Some(parent) = path.parent() {
223 if !parent.as_os_str().is_empty() {
224 std::fs::create_dir_all(parent)
225 .map_err(|e| CordisError::Configuration(e.to_string()))?;
226 }
227 }
228 let name = path
229 .file_name()
230 .and_then(|s| s.to_str())
231 .unwrap_or("entries");
232 let tmp = path.with_file_name(format!(
233 "{name}.tmp-{}-{}",
234 std::process::id(),
235 next_save_nonce()
236 ));
237 if let Err(e) = std::fs::write(&tmp, bytes).and_then(|_| std::fs::rename(&tmp, path)) {
238 let _ = std::fs::remove_file(&tmp);
239 return Err(CordisError::Configuration(e.to_string()));
240 }
241 Ok(())
242}
243
244use serde::{Deserialize, Serialize};
245
246use crate::{CordisError, LoaderJournal, Service};
247
248#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct EntryIntercept(pub HashMap<String, serde_json::Value>);
251
252impl Service for EntryIntercept {
253 fn name(&self) -> &'static str {
254 "entry_intercept"
255 }
256}
257
258#[derive(Debug, Deserialize, Serialize)]
260struct TomlEntries {
261 #[serde(default)]
262 entry: Vec<Entry>,
263}
264
265pub const ENTRIES_PATH: &str = "config/entries.json";
267
268pub const CORDIS_ENTRIES_TOON_PATH: &str = "config/cordis-entries.toon";
272
273#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct Entry {
281 pub id: String,
282 pub plugin: String,
283 #[serde(default)]
284 pub config: serde_json::Value,
285 #[serde(default)]
286 pub disabled: bool,
287 #[serde(default)]
288 pub isolate: Option<String>,
289 #[serde(default)]
290 pub intercept: HashMap<String, serde_json::Value>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub position: Option<EntryPosition>,
293}
294
295impl Default for Entry {
296 fn default() -> Self {
297 Self {
298 id: String::new(),
299 plugin: String::new(),
300 config: serde_json::Value::Null,
301 disabled: false,
302 isolate: None,
303 intercept: HashMap::new(),
304 position: None,
305 }
306 }
307}
308
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
319pub struct EntryPosition {
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub parent: Option<String>,
322 #[serde(default)]
323 pub position: usize,
324}
325
326#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
335pub struct EntryUpdate {
336 pub config: Option<serde_json::Value>,
337 pub disabled: Option<bool>,
338 pub isolate: Option<String>,
339 pub intercept: Option<std::collections::BTreeMap<String, serde_json::Value>>,
340 pub parent: Option<Option<String>>,
347 pub position: Option<usize>,
348}
349
350impl EntryUpdate {
351 pub fn apply_to(&self, entry: &mut Entry) {
355 if let Some(config) = &self.config {
356 entry.config = config.clone();
357 }
358 if let Some(disabled) = self.disabled {
359 entry.disabled = disabled;
360 }
361 if let Some(isolate) = &self.isolate {
362 entry.isolate = Some(isolate.clone());
363 }
364 if let Some(intercept) = &self.intercept {
365 entry.intercept = intercept
366 .iter()
367 .map(|(k, v)| (k.clone(), v.clone()))
368 .collect();
369 }
370 }
371}
372
373#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
375pub struct EntryTree(pub Vec<Entry>);
376
377impl EntryTree {
378 pub fn new(entries: Vec<Entry>) -> Self {
379 Self(entries)
380 }
381
382 pub fn len(&self) -> usize {
383 self.0.len()
384 }
385
386 pub fn is_empty(&self) -> bool {
387 self.0.is_empty()
388 }
389
390 pub fn iter(&self) -> std::slice::Iter<'_, Entry> {
391 self.0.iter()
392 }
393
394 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
396 serde_json::to_string_pretty(self)
397 }
398
399 pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
401 serde_json::from_str(s)
402 }
403
404 pub fn save_to_file(&self, path: &str) -> Result<(), CordisError> {
413 let json = serde_json::to_string_pretty(self)
414 .map_err(|e| CordisError::Configuration(e.to_string()))?;
415 write_atomic(Path::new(path), json.as_bytes())
416 }
417
418 pub fn load_from_file(path: &str) -> Result<Self, CordisError> {
419 let data =
420 std::fs::read_to_string(path).map_err(|e| CordisError::Configuration(e.to_string()))?;
421 serde_json::from_str(&data).map_err(|e| CordisError::Configuration(e.to_string()))
422 }
423
424 pub fn load_from_json_path(path: &Path) -> Result<Self, CordisError> {
427 let data = std::fs::read_to_string(path).map_err(|e| {
428 CordisError::Configuration(format!("failed to read {}: {}", path.display(), e))
429 })?;
430 serde_json::from_str(&data).map_err(|e| CordisError::Configuration(e.to_string()))
431 }
432
433 pub fn save_to_toml_file(&self, path: &Path) -> Result<(), CordisError> {
439 let mut header = String::new();
440 if let Ok(existing) = std::fs::read_to_string(path) {
441 for line in existing.lines() {
442 if line.starts_with('#') || line.trim().is_empty() {
443 header.push_str(line);
444 header.push('\n');
445 } else {
446 break;
447 }
448 }
449 }
450 let body = toml::to_string_pretty(&TomlEntries {
451 entry: self.0.clone(),
452 })
453 .map_err(|e| CordisError::Configuration(e.to_string()))?;
454 write_atomic(path, format!("{header}{body}").as_bytes())
457 }
458
459 pub const ID_SEP: char = ':';
465
466 fn leaf_id(id: &str) -> &str {
468 id.rsplit(Self::ID_SEP).next().unwrap_or(id)
469 }
470
471 pub fn children_ids(&self, parent: Option<&str>) -> Vec<String> {
475 self.0
476 .iter()
477 .filter(|e| e.position.as_ref().and_then(|p| p.parent.as_deref()) == parent)
478 .filter(|e| e.id != parent.unwrap_or(""))
479 .map(|e| e.id.clone())
480 .collect()
481 }
482
483 pub fn subtree_ids(&self, id: &str) -> Vec<String> {
487 let mut out: Vec<String> = Vec::new();
488 let mut frontier: Vec<String> = vec![id.to_string()];
489 while let Some(front) = frontier.pop() {
490 for e in &self.0 {
491 let linked = e.id.starts_with(&format!("{front}{}", Self::ID_SEP))
492 || e.position
493 .as_ref()
494 .and_then(|p| p.parent.as_deref())
495 == Some(front.as_str());
496 if linked && !out.contains(&e.id) && e.id != id {
497 out.push(e.id.clone());
498 frontier.push(e.id.clone());
499 }
500 }
501 }
502 out
503 }
504
505 pub fn move_entry(
520 &mut self,
521 id: &str,
522 target: Option<&str>,
523 position: usize,
524 ) -> Result<Vec<(String, String)>, String> {
525 if id.is_empty() {
526 return Err("cannot move the empty id".to_string());
527 }
528 if !self.0.iter().any(|e| e.id == id) {
529 return Err(format!("no such entry '{id}'"));
530 }
531 if let Some(t) = target {
532 if t == id {
533 return Err(format!(
534 "cannot move entry '{id}' under itself"
535 ));
536 }
537 if !self.0.iter().any(|e| e.id == t) {
538 return Err(format!("no such entry '{t}'"));
539 }
540 if self.subtree_ids(id).iter().any(|d| d == t) {
541 return Err(format!(
542 "cannot move entry '{id}' under its own descendant '{t}'"
543 ));
544 }
545 }
546
547 let new_root = match target {
549 Some(t) => format!("{t}{}{}", Self::ID_SEP, Self::leaf_id(id)),
550 None => Self::leaf_id(id).to_string(),
551 };
552 let mut renames: Vec<(String, String)> =
553 vec![(id.to_string(), new_root.clone())];
554 for desc in self.subtree_ids(id) {
555 let new_id = desc.replacen(&format!("{id}{}", Self::ID_SEP), &format!("{new_root}{}", Self::ID_SEP), 1);
556 renames.push((desc, new_id));
557 }
558
559 for (old, new) in &renames {
561 let in_subtree = renames.iter().any(|(o, _)| o == new);
562 if !in_subtree {
563 if let Some(existing) = self.0.iter().find(|e| &e.id == new) {
564 return Err(format!(
565 "cannot rename '{old}' to '{new}': id already used by plugin '{}'",
566 existing.plugin
567 ));
568 }
569 }
570 }
571 let map: HashMap<&str, &str> =
572 renames.iter().map(|(o, n)| (o.as_str(), n.as_str())).collect();
573
574 for e in self.0.iter_mut() {
576 if let Some(n) = map.get(e.id.as_str()) {
577 e.id = (*n).to_string();
578 }
579 if let Some(pos) = e.position.as_mut() {
580 if let Some(p) = pos.parent.as_deref() {
581 if let Some(n) = map.get(p) {
582 pos.parent = Some((*n).to_string());
583 }
584 }
585 }
586 }
587
588 let moved = self
590 .0
591 .iter_mut()
592 .find(|e| e.id == new_root)
593 .expect("renamed root just written");
594 let slot = moved.position.get_or_insert_with(EntryPosition::default);
595 slot.parent = target.map(str::to_string);
596 slot.position = position;
597 Ok(renames)
598 }
599}
600
601#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
609pub enum LoaderAction {
610 RebuildFiber {
611 id: String,
612 plugin: String,
613 },
614 UpdateConfig {
615 id: String,
616 new_config: serde_json::Value,
617 },
618 Retire {
619 id: String,
620 },
621 Begin {
622 id: String,
623 },
624}
625
626#[derive(Debug, Default, Clone)]
638pub struct Loader;
639
640impl Service for Loader {}
641
642#[derive(Debug, Clone, PartialEq, Eq)]
644pub struct MoveOutcome {
645 pub renamed: Vec<(String, String)>,
648 pub noop: bool,
652}
653
654impl Loader {
655 pub async fn move_entry(
676 ctx: &Arc<crate::Context>,
677 current: &mut EntryTree,
678 journal: &crate::LoaderJournal,
679 id: &str,
680 target: Option<&str>,
681 position: usize,
682 ) -> Result<MoveOutcome, CordisError> {
683 let before = current.clone();
684 let renamed = current
685 .move_entry(id, target, position)
686 .map_err(CordisError::Configuration)?;
687
688 let noop = Self::composition_equivalent(&before, current);
689 if noop {
690 for (old, new) in &renamed {
691 let Some(record) = journal.rename(old, new) else {
695 continue;
696 };
697 let Some(fid) = record.fiber_id else {
698 continue;
699 };
700 if let Some(fiber) = ctx
703 .get::<crate::RegistryService>()
704 .and_then(|rs| rs.get_fiber(fid))
705 {
706 fiber.set_epoch(new.clone());
707 }
708 if let Some(ledger) = ctx.get::<crate::cycles::CycleLedger>() {
709 ledger.note_entry(fid, new);
710 }
711 }
712 } else {
713 let desired = current.clone();
714 Self::apply(ctx, current, &desired, journal).await;
715 }
716
717 if let Some(shared) = ctx.get::<crate::CurrentEntries>() {
718 if let Ok(mut tree) = shared.tree.lock() {
719 *tree = current.clone();
720 }
721 }
722 Ok(MoveOutcome { renamed, noop })
723 }
724
725 fn composition_equivalent(a: &EntryTree, b: &EntryTree) -> bool {
729 let signature = |tree: &EntryTree| {
730 let mut sig: Vec<(String, String, bool, Option<String>)> = tree
731 .0
732 .iter()
733 .map(|e| {
734 (
735 e.plugin.clone(),
736 serde_json::to_string(&e.config).unwrap_or_default(),
737 e.disabled,
738 e.isolate.clone(),
739 )
740 })
741 .collect();
742 sig.sort();
743 sig
744 };
745 signature(a) == signature(b)
746 }
747}
748
749#[derive(Clone)]
753pub struct CurrentEntries {
754 pub tree: std::sync::Arc<std::sync::Mutex<EntryTree>>,
755 pub path: std::path::PathBuf,
756}
757
758impl Service for CurrentEntries {}
759
760pub trait EntryConfigFiller: Send + Sync {
764 fn fill_empty_entry_configs(&self, tree: &mut EntryTree);
765}
766
767#[derive(Clone)]
769pub struct EntryConfigFillerHandle(pub std::sync::Arc<dyn EntryConfigFiller>);
770
771impl Service for EntryConfigFillerHandle {}
772
773#[derive(Debug, Clone)]
775pub struct AppliedAction {
776 pub id: String,
777 pub action: &'static str,
779 pub status: Result<(), String>,
780 pub verified: bool,
784}
785
786static CASCADE_INFLIGHT: std::sync::LazyLock<std::sync::Mutex<HashMap<u64, u64>>> =
793 std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
794
795impl Loader {
796 pub async fn apply(
822 ctx: &Arc<crate::Context>,
823 current: &mut EntryTree,
824 desired: &EntryTree,
825 journal: &crate::LoaderJournal,
826 ) -> Vec<AppliedAction> {
827 use apply_staged::Staged;
828
829 let loader = Loader::new();
830 let actions = loader.reconcile(current, desired);
831 let ops = ctx.get::<LoaderOps>();
832 let mut staged: Vec<Staged> = Vec::with_capacity(actions.len());
835 let mut results: Vec<AppliedAction> = Vec::with_capacity(actions.len());
836
837 for action in &actions {
838 match action {
839 LoaderAction::Retire { id } => {
840 staged.push(Staged::Retire { id: id.clone() });
841 }
842 LoaderAction::UpdateConfig { id, new_config } => {
843 let old_config = current
844 .0
845 .iter()
846 .find(|e| e.id == *id)
847 .map(|e| e.config.clone())
848 .unwrap_or(serde_json::Value::Null);
849 if let Err(error) = Self::trial_config_verified(ctx, id, new_config) {
856 tracing::error!(entry_id = %id, error = %error,
857 "Loader: config pre-flight failed; old provider kept");
858 results.push(AppliedAction {
859 id: id.clone(),
860 action: "update-config",
861 status: Err(format!("config pre-flight failed: {error}")),
862 verified: true,
863 });
864 return results;
865 }
866 let fid = journal.get(id).and_then(|r| r.fiber_id);
867 staged.push(Staged::UpdateConfig {
868 id: id.clone(),
869 old_config,
870 new_config: new_config.clone(),
871 fid,
872 });
873 }
874 LoaderAction::Begin { id } => {
875 let Some(entry) = desired.0.iter().find(|e| &e.id == id) else {
876 results.push(AppliedAction {
877 id: id.clone(),
878 action: "begin",
879 status: Err(format!("entry '{id}' not found in desired tree")),
880 verified: true,
881 });
882 return results;
883 };
884 staged.push(Staged::Begin {
885 id: id.clone(),
886 entry: entry.clone(),
887 });
888 }
889 LoaderAction::RebuildFiber { id, plugin } => {
890 let Some(entry) = desired.0.iter().find(|e| &e.id == id) else {
891 results.push(AppliedAction {
892 id: id.clone(),
893 action: "rebuild-fiber",
894 status: Err(format!("entry '{id}' not found in desired tree")),
895 verified: false,
896 });
897 return results;
898 };
899 staged.push(Staged::RebuildFiber {
900 id: id.clone(),
901 entry: entry.clone(),
902 plugin: plugin.clone(),
903 });
904 }
905 }
906 }
907
908 let order_key = |s: &Staged| match s {
913 Staged::Begin { .. } | Staged::RebuildFiber { .. } => 0u8,
914 Staged::UpdateConfig { .. } => 1u8,
915 Staged::Retire { .. } => 2u8,
916 };
917 let tie_key = |s: &Staged| match s {
918 Staged::Retire { id }
919 | Staged::UpdateConfig { id, .. }
920 | Staged::Begin { id, .. }
921 | Staged::RebuildFiber { id, .. } => id.clone(),
922 };
923 staged.sort_by(|a, b| order_key(a).cmp(&order_key(b)).then(tie_key(a).cmp(&tie_key(b))));
924
925 let _window = ops.as_ref().map(|o| o.enter_loader_window());
930 let mut applied: Vec<Staged> = Vec::new();
931 let mut verified_for: HashMap<String, bool> = HashMap::new();
932
933 for step in staged {
934 let (id, kind): (String, &'static str) = match &step {
935 Staged::Retire { id } => (id.clone(), "retire"),
936 Staged::UpdateConfig { id, .. } => (id.clone(), "update-config"),
937 Staged::Begin { id, .. } => (id.clone(), "begin"),
938 Staged::RebuildFiber { id, .. } => (id.clone(), "rebuild-fiber"),
939 };
940 let (outcome, verified): (Result<(), String>, bool) = match step {
941 Staged::Retire { ref id } => {
942 if let Some(record) = journal.get(id) {
944 if let Some(fid) = record.fiber_id {
945 if let Some(fiber) = ctx
946 .get::<crate::RegistryService>()
947 .and_then(|rs| rs.get_fiber(fid))
948 {
949 if let Err(error) = fiber.dispose().await {
950 tracing::error!(id = %id, %error, "Loader: fiber stuck in transition during retire");
951 }
952 }
953 }
954 }
955 journal.retire(id);
956 tracing::info!(id = %id, "Loader: retired entry");
957 (Ok(()), true)
958 }
959 Staged::UpdateConfig { ref id, ref new_config, fid, .. } => {
960 journal.update_config(id, new_config.clone(), None);
961 if let Some(fiber) = fid.and_then(|f| {
963 ctx.get::<crate::RegistryService>()
964 .and_then(|rs| rs.get_fiber(f))
965 }) {
966 match Self::drive_fiber_update(ctx, &fiber) {
967 Ok(()) => (Ok(()), true),
968 Err(e) => (Err(e), false),
969 }
970 } else {
971 (Ok(()), true)
972 }
973 }
974 Staged::Begin { ref entry, .. } => match Self::instantiate_entry(ctx, entry) {
975 Ok(_fid) => (Ok(()), true),
976 Err(e) => (Err(e.to_string()), false),
977 },
978 Staged::RebuildFiber {
979 ref id,
980 ref entry,
981 ref plugin,
982 } => {
983 match Self::rebuild_fiber_verified(ctx, id, plugin, entry.clone(), journal).await
984 {
985 Ok(v) => (Ok(()), v),
986 Err(e) => (Err(e), false),
987 }
988 }
989 };
990 if let Err(err) = outcome {
991 Self::rollback_staged(ctx, &applied, journal).await;
994 results.push(AppliedAction {
995 id,
996 action: kind,
997 status: Err(format!("staged apply failed: {err}; batch rolled back")),
998 verified,
999 });
1000 return results;
1001 }
1002 verified_for.insert(id, verified);
1003 applied.push(step);
1004 }
1005
1006 Self::report_cycles(ctx);
1009 *current = desired.clone();
1010 let kind_of = |probe_id: &str| -> &'static str {
1013 match actions.iter().find(|a| match a {
1014 LoaderAction::Begin { id }
1015 | LoaderAction::UpdateConfig { id, .. }
1016 | LoaderAction::Retire { id }
1017 | LoaderAction::RebuildFiber { id, .. } => id == probe_id,
1018 }) {
1019 Some(LoaderAction::Begin { .. }) => "begin",
1020 Some(LoaderAction::UpdateConfig { .. }) => "update-config",
1021 Some(LoaderAction::Retire { .. }) => "retire",
1022 _ => "rebuild-fiber",
1023 }
1024 };
1025 for id in verified_for.keys() {
1026 let verified = verified_for[id];
1029 results.push(AppliedAction {
1030 id: id.clone(),
1031 action: kind_of(id),
1032 status: Ok(()),
1033 verified,
1034 });
1035 }
1036 results
1037 }
1038
1039 fn cascade_begin(fid: u64) {
1057 if let Ok(mut ledger) = CASCADE_INFLIGHT.lock() {
1058 *ledger.entry(fid).or_insert(0) += 1;
1059 }
1060 }
1061
1062 fn cascade_end(fid: u64) -> bool {
1065 if let Ok(mut ledger) = CASCADE_INFLIGHT.lock() {
1066 match ledger.entry(fid) {
1067 std::collections::hash_map::Entry::Occupied(mut slot) => {
1068 *slot.get_mut() -= 1;
1069 if *slot.get() == 0 {
1070 slot.remove();
1071 return true;
1072 }
1073 return false;
1074 }
1075 std::collections::hash_map::Entry::Vacant(_) => return true,
1076 }
1077 }
1078 true
1079 }
1080
1081 pub(crate) fn cascade_defer_needed(tids: &[TypeId], ctx: &Arc<crate::Context>) -> bool {
1086 let Some(registry) = ctx.get::<crate::RegistryService>() else {
1087 return false;
1088 };
1089 let provider_fids = registry.provider_fibers_for(ctx, tids);
1090 Self::cascade_any_inflight(&provider_fids)
1091 }
1092
1093 pub(crate) fn cascade_any_inflight(fids: &[u64]) -> bool {
1097 if fids.is_empty() {
1098 return false;
1099 }
1100 CASCADE_INFLIGHT
1101 .lock()
1102 .map(|ledger| fids.iter().any(|fid| ledger.contains_key(fid)))
1103 .unwrap_or(false)
1104 }
1105
1106 fn drive_fiber_update(
1115 ctx: &Arc<crate::Context>,
1116 fiber: &std::sync::Arc<crate::Fiber>,
1117 ) -> Result<(), String> {
1118 let fid = fiber.fiber_id().unwrap_or(0);
1119 Self::cascade_begin(fid);
1120 let outcome = match tokio::runtime::Handle::try_current() {
1121 Ok(handle) => {
1122 let ctx_ref = ctx.clone();
1123 let fiber_ref = fiber.clone();
1124 tokio::task::block_in_place(move || {
1125 handle
1126 .block_on(async move { fiber_ref.update(&ctx_ref).await })
1127 .map_err(|e| e.to_string())
1128 })
1129 }
1130 Err(_) => Err("no tokio runtime for live fiber update".to_string()),
1131 };
1132 let settled = Self::cascade_end(fid);
1133 if settled && fid != 0 {
1134 tracing::debug!(fiber_id = fid, "Loader: provider update settled; cascade converges");
1135 }
1136 outcome
1137 }
1138
1139 async fn rollback_staged(
1149 ctx: &Arc<crate::Context>,
1150 applied: &[apply_staged::Staged],
1151 journal: &crate::LoaderJournal,
1152 ) {
1153 for step in applied.iter().rev() {
1154 match step {
1155 apply_staged::Staged::UpdateConfig {
1156 id,
1157 old_config,
1158 fid,
1159 ..
1160 } => {
1161 journal.update_config(id, old_config.clone(), None);
1162 if let Some(fiber) = fid.and_then(|f| {
1163 ctx.get::<crate::RegistryService>()
1164 .and_then(|rs| rs.get_fiber(f))
1165 }) {
1166 let _ = Self::drive_fiber_update(ctx, &fiber);
1167 }
1168 }
1169 apply_staged::Staged::RebuildFiber { id, .. } => {
1170 if let Some(record) = journal.get(id) {
1174 if let Some(fid) = record.fiber_id {
1175 if let Some(fiber) = ctx
1176 .get::<crate::RegistryService>()
1177 .and_then(|rs| rs.get_fiber(fid))
1178 {
1179 let _ = fiber.dispose().await;
1180 }
1181 }
1182 }
1183 }
1184 apply_staged::Staged::Begin { entry, .. } => {
1185 if let Some(record) = journal.get(&entry.id) {
1186 if let Some(fid) = record.fiber_id {
1187 if let Some(fiber) = ctx
1188 .get::<crate::RegistryService>()
1189 .and_then(|rs| rs.get_fiber(fid))
1190 {
1191 let _ = fiber.dispose().await;
1192 }
1193 }
1194 }
1195 journal.retire(&entry.id);
1196 }
1197 apply_staged::Staged::Retire { .. } => {}
1198 }
1199 }
1200 }
1201}
1202
1203mod apply_staged {
1207 use crate::loader::Entry;
1208
1209 pub(super) enum Staged {
1210 Retire {
1211 id: String,
1212 },
1213 UpdateConfig {
1214 id: String,
1215 old_config: serde_json::Value,
1216 new_config: serde_json::Value,
1217 fid: Option<crate::FiberId>,
1218 },
1219 Begin {
1220 id: String,
1221 entry: Entry,
1222 },
1223 RebuildFiber {
1224 id: String,
1225 entry: Entry,
1226 plugin: String,
1227 },
1228 }
1229}
1230
1231impl Loader {
1232 pub fn detect_cycles(ctx: &Arc<crate::Context>) -> Vec<Vec<crate::FiberId>> {
1241 match crate::cycles::build_dependency_graph(ctx) {
1242 Some(graph) => crate::cycles::find_dependency_cycles(&graph),
1243 None => Vec::new(),
1244 }
1245 }
1246
1247 pub fn detect_cycle_entry_ids(ctx: &Arc<crate::Context>) -> Vec<Vec<String>> {
1251 let cycles = Self::detect_cycles(ctx);
1252 let journal = ctx.get::<crate::LoaderJournal>();
1253 Self::cycle_entry_ids(journal.as_deref(), &cycles)
1254 }
1255
1256 fn cycle_entry_ids(
1259 journal: Option<&crate::LoaderJournal>,
1260 cycles: &[Vec<crate::FiberId>],
1261 ) -> Vec<Vec<String>> {
1262 cycles
1263 .iter()
1264 .map(|cycle| {
1265 cycle
1266 .iter()
1267 .map(|fid| {
1268 journal
1269 .and_then(|j| {
1270 j.records.read().iter().find_map(|(id, rec)| {
1271 (rec.fiber_id == Some(*fid)).then(|| id.clone())
1272 })
1273 })
1274 .unwrap_or_else(|| fid.to_string())
1275 })
1276 .collect()
1277 })
1278 .collect()
1279 }
1280
1281 fn report_cycles(ctx: &Arc<crate::Context>) {
1286 let journal = ctx.get::<crate::LoaderJournal>();
1287 let cycles = Self::detect_cycles(ctx);
1288 if cycles.is_empty() {
1289 return;
1290 }
1291 let entry_ids = Self::cycle_entry_ids(journal.as_deref(), &cycles);
1292 tracing::warn!(
1293 entry_ids = ?entry_ids,
1294 fibers = ?cycles,
1295 "dependency cycle detected among loaded entries; affected fibers will remain inactive until the cycle is broken"
1296 );
1297 }
1298}
1299
1300impl Loader {
1301 pub fn new() -> Self {
1302 Self
1303 }
1304
1305 pub fn persist_path() -> &'static str {
1307 ENTRIES_PATH
1308 }
1309
1310 pub fn toon_path() -> &'static str {
1312 CORDIS_ENTRIES_TOON_PATH
1313 }
1314
1315 pub fn load_from_file(path: &std::path::Path) -> Result<EntryTree, CordisError> {
1327 let content = std::fs::read_to_string(path).map_err(|e| {
1328 CordisError::Configuration(format!("failed to read {}: {}", path.display(), e))
1329 })?;
1330 let parsed: TomlEntries = toml::from_str(&content).map_err(|e| {
1331 CordisError::Configuration(format!("failed to parse {}: {}", path.display(), e))
1332 })?;
1333 Ok(EntryTree(parsed.entry))
1334 }
1335
1336 pub fn reconcile(&self, current: &EntryTree, desired: &EntryTree) -> Vec<LoaderAction> {
1346 let mut curr_map: HashMap<&str, &Entry> = HashMap::new();
1347 for e in ¤t.0 {
1348 curr_map.insert(e.id.as_str(), e);
1349 }
1350 let mut desired_map: HashMap<&str, &Entry> = HashMap::new();
1351 for e in &desired.0 {
1352 desired_map.insert(e.id.as_str(), e);
1353 }
1354
1355 let mut actions: Vec<LoaderAction> = Vec::new();
1356
1357 for id in curr_map.keys() {
1359 if !desired_map.contains_key(*id) {
1360 actions.push(LoaderAction::Retire {
1361 id: (*id).to_string(),
1362 });
1363 }
1364 }
1365
1366 for (id, desired_entry) in &desired_map {
1367 match curr_map.get(*id) {
1368 None => {
1369 if !desired_entry.disabled {
1371 actions.push(LoaderAction::Begin {
1372 id: (*id).to_string(),
1373 });
1374 }
1375 }
1376 Some(curr_entry) => {
1377 if curr_entry.plugin != desired_entry.plugin {
1379 actions.push(LoaderAction::RebuildFiber {
1380 id: (*id).to_string(),
1381 plugin: desired_entry.plugin.clone(),
1382 });
1383 continue;
1384 }
1385 if curr_entry.isolate != desired_entry.isolate
1387 || curr_entry.intercept != desired_entry.intercept
1388 {
1389 actions.push(LoaderAction::RebuildFiber {
1390 id: (*id).to_string(),
1391 plugin: desired_entry.plugin.clone(),
1392 });
1393 continue;
1394 }
1395 if curr_entry.config != desired_entry.config {
1397 actions.push(LoaderAction::UpdateConfig {
1398 id: (*id).to_string(),
1399 new_config: desired_entry.config.clone(),
1400 });
1401 continue;
1402 }
1403 if curr_entry.disabled != desired_entry.disabled {
1405 if desired_entry.disabled {
1406 actions.push(LoaderAction::Retire {
1407 id: (*id).to_string(),
1408 });
1409 } else {
1410 actions.push(LoaderAction::Begin {
1411 id: (*id).to_string(),
1412 });
1413 }
1414 continue;
1415 }
1416 }
1417 }
1418 }
1419
1420 actions
1421 }
1422
1423 pub fn execute_action(action: &LoaderAction, ctx: &std::sync::Arc<crate::Context>) {
1438 let journal = ctx.get::<LoaderJournal>();
1439 let registry = ctx.get::<crate::PluginRegistry>();
1440 match action {
1441 LoaderAction::RebuildFiber { id, plugin } => {
1442 let Some(registry) = registry else {
1443 tracing::warn!(id = %id, plugin = %plugin,
1444 "PluginRegistry not provided; loader actions are log-only");
1445 return;
1446 };
1447 match registry
1448 .get(plugin)
1449 .ok_or_else(|| {
1450 crate::CordisError::Configuration(format!(
1451 "no factory registered for plugin '{plugin}'"
1452 ))
1453 })
1454 .and_then(|factory| factory(ctx, &serde_json::Value::Null))
1455 {
1456 Ok(fid) => {
1457 if let Some(journal) = &journal {
1458 journal.upsert(id, plugin, serde_json::Value::Null, Some(fid));
1459 }
1460 tracing::info!(id = %id, plugin = %plugin, fiber_id = %fid,
1461 "Loader: rebuilt fiber for entry");
1462 }
1463 Err(e) => {
1464 tracing::warn!(id = %id, plugin = %plugin, error = %e, "Loader: rebuild failed");
1465 }
1466 }
1467 }
1468 LoaderAction::UpdateConfig { id, new_config } => {
1469 let Some(journal) = journal else {
1470 tracing::info!(id = %id, "Loader: updating fiber config for entry");
1471 return;
1472 };
1473 let recorded = journal.get(id).and_then(|r| r.fiber_id);
1477 let fiber = if let Some(fid) = recorded {
1478 ctx.get::<crate::RegistryService>()
1479 .and_then(|rs| rs.get_fiber(fid))
1480 } else {
1481 None
1482 };
1483 if let Some(fiber) = fiber {
1484 match tokio::runtime::Handle::try_current() {
1491 Ok(handle)
1492 if handle.runtime_flavor()
1493 == tokio::runtime::RuntimeFlavor::CurrentThread =>
1494 {
1495 tracing::info!(id = %id,
1496 "Loader: current-thread runtime; fiber config update is journal-only");
1497 }
1498 Ok(handle) => {
1499 tracing::info!(id = %id, "Loader: applying fiber config update (live fiber)");
1500 let ctx_ref = ctx.clone();
1501 let fiber_ref = fiber.clone();
1502 let _ = tokio::task::block_in_place(move || {
1506 handle.block_on(fiber_ref.update(&ctx_ref))
1507 });
1508 }
1509 Err(_) => {
1510 tracing::info!(id = %id,
1511 "Loader: no tokio runtime in scope; fiber config update is journal-only");
1512 }
1513 }
1514 } else {
1515 tracing::info!(id = %id, "Loader: no live fiber for entry; journal-only config update");
1516 }
1517 journal.update_config(id, new_config.clone(), recorded);
1518 tracing::info!(id = %id, config = %new_config, "Loader: updated fiber config for entry");
1519 }
1520 LoaderAction::Retire { id } => {
1521 if let Some(journal) = &journal {
1522 if let Some(removed) = journal.retire(id) {
1523 tracing::info!(id = %id, plugin = %removed.plugin,
1524 "Loader: retired entry (journal record cleared)");
1525 } else {
1526 tracing::info!(id = %id, "Loader: retiring entry (no journal record)");
1527 }
1528 } else {
1529 tracing::info!(id = %id, "Loader: retiring entry");
1530 }
1531 }
1532 LoaderAction::Begin { id } => {
1533 tracing::info!(id = %id, "Loader: beginning entry");
1537 }
1538 }
1539 }
1540
1541 pub async fn reload_current(
1549 ctx: &Arc<crate::Context>,
1550 path: &std::path::Path,
1551 current: &mut EntryTree,
1552 desired_composed: &EntryTree,
1553 journal: &crate::LoaderJournal,
1554 ) -> Option<Vec<AppliedAction>> {
1555 tracing::debug!(
1558 path = %path.display(),
1559 entries = desired_composed.0.len(),
1560 "Cordis hot-reload: applying composed desired state"
1561 );
1562 let mut desired = desired_composed.clone();
1563 if let Some(handle) = ctx.get::<crate::loader::EntryConfigFillerHandle>() {
1564 handle.0.fill_empty_entry_configs(&mut desired);
1565 }
1566 Some(Self::apply(ctx, current, &desired, journal).await)
1567 }
1568
1569 async fn rebuild_fiber_verified(
1590 ctx: &Arc<crate::Context>,
1591 id: &str,
1592 plugin_name: &str,
1593 entry: Entry,
1594 journal: &crate::LoaderJournal,
1595 ) -> Result<bool, String> {
1596 let registry = ctx.get::<crate::RegistryService>();
1597 let old_fid = journal.get(id).and_then(|r| r.fiber_id);
1598 let old_fiber = old_fid
1599 .as_ref()
1600 .and_then(|fid| registry.as_ref()?.get_fiber(*fid));
1601 let Some((registry, old_fiber, old_fid)) = registry
1602 .zip(old_fiber)
1603 .zip(old_fid)
1604 .map(|((r, f), i)| (r, f, i))
1605 else {
1606 tracing::warn!(entry_id = %id, plugin = %plugin_name, swap_mode = "unverified",
1607 "Loader: no tracked fiber for rebuild; dispose-then-rebuild");
1608 return Self::retire_then_instantiate(ctx, entry)
1609 .await
1610 .map(|_| false);
1611 };
1612 if entry.isolate.is_some() {
1613 tracing::warn!(entry_id = %id, plugin = %plugin_name, swap_mode = "unverified",
1614 "Loader: isolated entry rebuild; dispose-then-rebuild");
1615 return Self::retire_then_instantiate(ctx, entry)
1616 .await
1617 .map(|_| false);
1618 }
1619
1620 let scratch = ctx.extend();
1625 let Some(plugin_registry) = scratch.get::<crate::PluginRegistry>() else {
1626 return Err("PluginRegistry missing".to_string());
1627 };
1628 let Some(factory) = plugin_registry.get(&entry.plugin) else {
1629 return Err(format!("no factory registered for plugin '{plugin_name}'"));
1630 };
1631 let trial_fiber = std::sync::Arc::new(crate::Fiber::new());
1632 trial_fiber.set_state(crate::FiberState::Loading);
1633 let trial = scratch.with_provider_fiber(&trial_fiber, || factory(&scratch, &entry.config));
1634 if let Some(reflect) = ctx.get::<crate::ReflectService>() {
1637 reflect.set_context(ctx);
1638 }
1639 if let Err(e) = trial {
1640 tracing::warn!(entry_id = %id, plugin = %plugin_name, error = %e,
1641 "Loader: verified swap trial failed; old provider kept");
1642 return Err(e.to_string());
1643 }
1644
1645 let built: Vec<TypeId> = scratch.provided_type_ids();
1649 let replaced: Vec<TypeId> = built
1650 .iter()
1651 .copied()
1652 .filter(|tid| ctx.get_untyped(*tid).is_some())
1653 .collect();
1654 if replaced.is_empty() {
1655 tracing::warn!(entry_id = %id, plugin = %plugin_name, swap_mode = "unverified",
1656 "Loader: trial produced no comparable services; dispose-then-rebuild");
1657 return Self::retire_then_instantiate(ctx, entry)
1658 .await
1659 .map(|_| false);
1660 }
1661
1662 let new_fid = SwapPromotion {
1663 ctx,
1664 registry: registry.as_ref(),
1665 scratch: &scratch,
1666 epoch: &entry.id,
1667 intercept_overlay: Some(&entry.intercept),
1668 built: &built,
1669 replaced: &replaced,
1670 old_fiber,
1671 old_fid,
1672 }
1673 .run()
1674 .await;
1675 journal.upsert(id, &entry.plugin, entry.config.clone(), Some(new_fid));
1676 tracing::info!(entry_id = %id, plugin = %plugin_name, old_fiber_id = %old_fid,
1677 new_fiber_id = %new_fid, swap_mode = "verified",
1678 "Loader: hot-swapped provider with verification");
1679 Ok(true)
1680 }
1681
1682 fn trial_config_verified(
1693 ctx: &Arc<crate::Context>,
1694 id: &str,
1695 new_config: &serde_json::Value,
1696 ) -> Result<(), String> {
1697 let scratch = ctx.extend();
1698 let Some(plugin_registry) = scratch.get::<crate::PluginRegistry>() else {
1699 return Ok(());
1700 };
1701 let Some(record) = ctx.get::<crate::LoaderJournal>().and_then(|j| j.get(id)) else {
1704 return Ok(());
1705 };
1706 let Some(factory) = plugin_registry.get(&record.plugin) else {
1707 return Ok(());
1708 };
1709 let trial_fiber = std::sync::Arc::new(crate::Fiber::new());
1710 trial_fiber.set_state(crate::FiberState::Loading);
1711 let trial =
1712 scratch.with_provider_fiber(&trial_fiber, || factory(&scratch, &new_config.clone()));
1713 if let Some(reflect) = ctx.get::<crate::ReflectService>() {
1716 reflect.set_context(ctx);
1717 }
1718 trial.map(|_| ()).map_err(|e| {
1719 crate::error::stash_trial_validation(id, &e);
1723 e.to_string()
1724 })
1725 }
1726
1727 pub fn take_trial_validation(entry_id: &str) -> Option<crate::error::ValidationError> {
1737 crate::error::take_trial_validation(entry_id)
1738 }
1739
1740 pub async fn replace_provider(
1775 &self,
1776 ctx: &Arc<crate::Context>,
1777 plugin_name: &str,
1778 config: serde_json::Value,
1779 journal: &crate::LoaderJournal,
1780 ) -> Result<crate::FiberId, CordisError> {
1781 let (id, record) = journal
1783 .records
1784 .read()
1785 .iter()
1786 .find(|(_, rec)| rec.plugin == plugin_name)
1787 .map(|(id, rec)| (id.clone(), rec.clone()))
1788 .ok_or_else(|| {
1789 CordisError::Configuration(format!(
1790 "replace_provider: no journaled entry for plugin '{plugin_name}'"
1791 ))
1792 })?;
1793 let old_fid = record.fiber_id.ok_or_else(|| {
1794 CordisError::Configuration(format!(
1795 "replace_provider: entry '{id}' has no tracked fiber"
1796 ))
1797 })?;
1798 let registry = ctx
1799 .get::<crate::RegistryService>()
1800 .ok_or_else(|| CordisError::Configuration("RegistryService missing".into()))?;
1801 let old_fiber = registry.get_fiber(old_fid).ok_or_else(|| {
1802 CordisError::Configuration(format!(
1803 "replace_provider: fiber {old_fid} for entry '{id}' not tracked"
1804 ))
1805 })?;
1806
1807 let scratch = ctx.extend();
1811 let Some(plugin_registry) = scratch.get::<crate::PluginRegistry>() else {
1812 return Err(CordisError::Configuration("PluginRegistry missing".into()));
1813 };
1814 let Some(factory) = plugin_registry.get(plugin_name) else {
1815 return Err(CordisError::Configuration(format!(
1816 "no factory registered for plugin '{plugin_name}'"
1817 )));
1818 };
1819 let trial_fiber = std::sync::Arc::new(crate::Fiber::new());
1820 trial_fiber.set_state(crate::FiberState::Loading);
1821 let trial = scratch.with_provider_fiber(&trial_fiber, || factory(&scratch, &config));
1822 if let Some(reflect) = ctx.get::<crate::ReflectService>() {
1825 reflect.set_context(ctx);
1826 }
1827 let built: Vec<TypeId> = match trial {
1828 Ok(_) => scratch.provided_type_ids(),
1829 Err(e) => {
1830 tracing::warn!(entry_id = %id, plugin = %plugin_name, error = %e,
1831 "Loader: replace_provider trial failed; old provider kept");
1832 return Err(e);
1833 }
1834 };
1835
1836 if let Some(isolated) = built
1839 .iter()
1840 .copied()
1841 .find(|tid| ctx.isolate_label(*tid).is_some())
1842 {
1843 return Err(CordisError::Configuration(format!(
1844 "replace_provider: isolated providers not supported yet \
1845 (trial built an isolated service, e.g. {isolated:?})"
1846 )));
1847 }
1848 let replaced: Vec<TypeId> = built
1849 .iter()
1850 .copied()
1851 .filter(|tid| ctx.get_untyped(*tid).is_some())
1852 .collect();
1853 if replaced.is_empty() {
1854 return Err(CordisError::Configuration(format!(
1858 "replace_provider: trial produced no comparable services for '{plugin_name}'"
1859 )));
1860 }
1861
1862 let new_fid = SwapPromotion {
1863 ctx,
1864 registry: registry.as_ref(),
1865 scratch: &scratch,
1866 epoch: &id,
1867 intercept_overlay: None,
1868 built: &built,
1869 replaced: &replaced,
1870 old_fiber,
1871 old_fid,
1872 }
1873 .run()
1874 .await;
1875
1876 journal.upsert(&id, plugin_name, config, Some(new_fid));
1877 tracing::info!(entry_id = %id, plugin = %plugin_name, old_fiber_id = %old_fid,
1878 new_fiber_id = %new_fid, swap_mode = "verified",
1879 "Loader: replace_provider swapped provider with zero absence window");
1880 Ok(new_fid)
1881 }
1882
1883 async fn retire_then_instantiate(
1886 ctx: &Arc<crate::Context>,
1887 entry: Entry,
1888 ) -> Result<(), String> {
1889 match Self::instantiate_entry(ctx, &entry) {
1890 Ok(_fid) => Ok(()),
1891 Err(e) => Err(e.to_string()),
1892 }
1893 }
1894
1895 pub fn instantiate(
1905 ctx: &Arc<crate::Context>,
1906 plugin_name: &str,
1907 config: &serde_json::Value,
1908 entry_id: &str,
1909 ) -> Result<crate::FiberId, crate::CordisError> {
1910 Self::instantiate_entry(
1911 ctx,
1912 &Entry {
1913 id: entry_id.to_string(),
1914 plugin: plugin_name.to_string(),
1915 config: config.clone(),
1916 disabled: false,
1917 isolate: None,
1918 intercept: HashMap::new(),
1919 position: None,
1920 },
1921 )
1922 }
1923
1924 pub fn instantiate_entry(
1930 ctx: &Arc<crate::Context>,
1931 entry: &Entry,
1932 ) -> Result<crate::FiberId, crate::CordisError> {
1933 if !entry.intercept.is_empty() {
1934 ctx.bind_intercept(EntryIntercept(entry.intercept.clone()));
1935 }
1936 let before: HashSet<TypeId> = ctx.provided_type_ids().into_iter().collect();
1937 let Some(registry) = ctx.get::<crate::PluginRegistry>() else {
1938 return Err(crate::CordisError::Configuration(
1939 "PluginRegistry missing".into(),
1940 ));
1941 };
1942 let Some(factory) = registry.get(&entry.plugin) else {
1943 return Err(crate::CordisError::Configuration(format!(
1944 "no factory registered for plugin '{}'",
1945 entry.plugin
1946 )));
1947 };
1948 let fiber = std::sync::Arc::new(crate::Fiber::new());
1952 fiber.set_state(crate::FiberState::Loading);
1953 let tracked = ctx
1957 .get::<crate::RegistryService>()
1958 .map(|rs| rs.track_fiber(fiber.clone()));
1959 #[allow(unused_variables)]
1962 let fid = tracked.unwrap_or_else(|| {
1963 crate::context::NEXT_FIBER_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst) as u64
1964 });
1965 fiber.set_state(crate::FiberState::Active {
1969 epoch: entry.id.clone(),
1970 });
1971 let outcome = ctx.with_provider_fiber(&fiber, || factory(ctx, &entry.config));
1972 let fid = match outcome {
1976 Ok(_factory_fid) => tracked.unwrap_or_else(|| {
1977 crate::context::NEXT_FIBER_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
1978 as u64
1979 }),
1980 Err(e) => {
1981 fiber.set_state(crate::FiberState::Failed {
1982 error: Some(e.to_string()),
1983 });
1984 return Err(e);
1985 }
1986 };
1987 if ctx.get::<crate::cycles::CycleLedger>().is_none() {
1992 ctx.provide(crate::cycles::CycleLedger::new());
1993 }
1994 let ledger = ctx
1995 .get::<crate::cycles::CycleLedger>()
1996 .expect("ledger just provided");
1997 for tid in ctx.provided_type_ids() {
1998 if !before.contains(&tid) {
1999 ledger.record_provider(tid, ctx.isolate_label(tid).as_deref(), fid);
2000 }
2001 }
2002 ledger.note_entry(fid, &entry.id);
2003 if let Some(label) = entry.isolate.as_deref() {
2004 for tid in ctx.provided_type_ids() {
2005 if !before.contains(&tid) {
2006 ctx.bind_isolate(tid, label);
2007 }
2008 }
2009 }
2010 if let Some(journal) = ctx.get::<LoaderJournal>() {
2011 journal.upsert(&entry.id, &entry.plugin, entry.config.clone(), Some(fid));
2012 }
2013 if let Some(ops) = ctx.get::<LoaderOps>() {
2017 ops.record_apply(&entry.id);
2018 Self::watch_entry_fiber(&ops, &fiber, &entry.id);
2019 }
2020 tracing::info!(entry_id=%entry.id, plugin=%entry.plugin, fiber_id=%fid, "Loader: instantiated plugin");
2021 Ok(fid)
2022 }
2023
2024 fn watch_entry_fiber(
2034 ops: &std::sync::Arc<LoaderOps>,
2035 fiber: &std::sync::Arc<crate::Fiber>,
2036 entry_id: &str,
2037 ) {
2038 let ops_ref = std::sync::Arc::downgrade(ops);
2039 let entry = entry_id.to_string();
2040 let handle = fiber.subscribe_state(Box::new(move |state| {
2043 let Some(ops) = ops_ref.upgrade() else {
2044 return;
2045 };
2046 if !ops.in_loader_window()
2047 && matches!(
2048 state,
2049 crate::FiberState::Unloading { .. } | crate::FiberState::Inactive { .. }
2050 )
2051 {
2052 ops.persist_self_kill(&entry);
2053 }
2054 }));
2055 drop(handle);
2060 }
2061}
2062
2063struct SwapPromotion<'a> {
2082 ctx: &'a Arc<crate::Context>,
2083 registry: &'a crate::RegistryService,
2084 scratch: &'a Arc<crate::Context>,
2085 epoch: &'a str,
2087 intercept_overlay: Option<&'a HashMap<String, serde_json::Value>>,
2090 built: &'a [TypeId],
2092 replaced: &'a [TypeId],
2095 old_fiber: Arc<crate::Fiber>,
2096 old_fid: crate::FiberId,
2097}
2098
2099impl SwapPromotion<'_> {
2100 async fn run(&self) -> crate::FiberId {
2101 if let Some(overlay) = self.intercept_overlay.filter(|o| !o.is_empty()) {
2104 self.ctx.bind_intercept(EntryIntercept(overlay.clone()));
2105 }
2106
2107 for tid in self.replaced {
2110 if let Some(any) = self.scratch.get_untyped(*tid) {
2111 self.ctx.bind_intercept_untyped(*tid, any);
2112 }
2113 }
2114
2115 let _ = self.old_fiber.dispose().await;
2125 self.registry.remove(self.old_fid);
2126
2127 for tid in self.replaced {
2131 if let Some(any) = self.ctx.peek_intercept_untyped(*tid) {
2132 self.ctx.take_untyped(*tid);
2138 let _ = self.ctx.provide_untyped(*tid, any);
2139 self.ctx.remove_intercept_untyped(*tid);
2140 }
2141 }
2142 for tid in self.built {
2145 if self.replaced.contains(tid) || self.ctx.get_untyped(*tid).is_some() {
2146 continue;
2147 }
2148 if let Some(any) = self.scratch.get_untyped(*tid) {
2149 let _ = self.ctx.provide_untyped(*tid, any);
2150 }
2151 }
2152
2153 let fiber = std::sync::Arc::new(crate::Fiber::new());
2155 fiber.set_state(crate::FiberState::Active {
2156 epoch: self.epoch.to_string(),
2157 });
2158 let new_fid = self.registry.track_fiber(fiber);
2159 self.registry.track_fiber_in_realm(new_fid, self.ctx);
2160 new_fid
2161 }
2162}
2163
2164#[cfg(test)]
2165mod tests {
2166 use super::*;
2167 use crate::Context;
2168 use serde_json::json;
2169 use std::sync::Arc;
2170
2171 #[test]
2172 fn entry_json_round_trip() {
2173 let entry = Entry {
2174 id: "tool:calc".into(),
2175 plugin: "CalculatorService".into(),
2176 config: json!({"precision": 2}),
2177 disabled: false,
2178 isolate: Some("tenant:acme".into()),
2179 intercept: HashMap::new(),
2180 position: None,
2181 };
2182 let s = serde_json::to_string(&entry).unwrap();
2183 let back: Entry = serde_json::from_str(&s).unwrap();
2184 assert_eq!(entry, back);
2185 }
2186
2187 #[test]
2188 fn entry_tree_json_round_trip() {
2189 let tree = EntryTree(vec![
2190 Entry {
2191 id: "a".into(),
2192 plugin: "Foo".into(),
2193 config: json!({"x": 1}),
2194 disabled: false,
2195 isolate: None,
2196 intercept: HashMap::new(),
2197 position: None,
2198 },
2199 Entry {
2200 id: "b".into(),
2201 plugin: "Bar".into(),
2202 config: json!(null),
2203 disabled: true,
2204 isolate: None,
2205 intercept: HashMap::new(),
2206 position: None,
2207 },
2208 ]);
2209 let s = serde_json::to_string(&tree).unwrap();
2210 let back: EntryTree = serde_json::from_str(&s).unwrap();
2211 assert_eq!(tree, back);
2212 let pretty = tree.to_json_pretty().unwrap();
2213 let back2 = EntryTree::from_json(&pretty).unwrap();
2214 assert_eq!(tree, back2);
2215 }
2216
2217 #[test]
2218 fn reconcile_config_change() {
2219 let cur = EntryTree(vec![Entry {
2220 id: "a".into(),
2221 plugin: "Foo".into(),
2222 config: json!({"v": 1}),
2223 disabled: false,
2224 isolate: None,
2225 intercept: HashMap::new(),
2226 position: None,
2227 }]);
2228 let des = EntryTree(vec![Entry {
2229 id: "a".into(),
2230 plugin: "Foo".into(),
2231 config: json!({"v": 2}),
2232 disabled: false,
2233 isolate: None,
2234 intercept: HashMap::new(),
2235 position: None,
2236 }]);
2237 let loader = Loader::new();
2238 let acts = loader.reconcile(&cur, &des);
2239 assert_eq!(acts.len(), 1);
2240 assert!(matches!(acts[0], LoaderAction::UpdateConfig { .. }));
2241 }
2242
2243 #[test]
2244 fn reconcile_disabled_toggle() {
2245 let cur = EntryTree(vec![Entry {
2246 id: "a".into(),
2247 plugin: "Foo".into(),
2248 config: json!(null),
2249 disabled: false,
2250 isolate: None,
2251 intercept: HashMap::new(),
2252 position: None,
2253 }]);
2254 let des = EntryTree(vec![Entry {
2255 id: "a".into(),
2256 plugin: "Foo".into(),
2257 config: json!(null),
2258 disabled: true,
2259 isolate: None,
2260 intercept: HashMap::new(),
2261 position: None,
2262 }]);
2263 let loader = Loader::new();
2264 assert!(matches!(
2265 loader.reconcile(&cur, &des)[0],
2266 LoaderAction::Retire { .. }
2267 ));
2268 assert!(matches!(
2269 loader.reconcile(&des, &cur)[0],
2270 LoaderAction::Begin { .. }
2271 ));
2272 }
2273
2274 #[test]
2275 fn reconcile_plugin_change_rebuild() {
2276 let cur = EntryTree(vec![Entry {
2277 id: "a".into(),
2278 plugin: "Foo".into(),
2279 config: json!(null),
2280 disabled: false,
2281 isolate: None,
2282 intercept: HashMap::new(),
2283 position: None,
2284 }]);
2285 let des = EntryTree(vec![Entry {
2286 id: "a".into(),
2287 plugin: "Bar".into(),
2288 config: json!(null),
2289 disabled: false,
2290 isolate: None,
2291 intercept: HashMap::new(),
2292 position: None,
2293 }]);
2294 let loader = Loader::new();
2295 assert!(matches!(
2296 loader.reconcile(&cur, &des)[0],
2297 LoaderAction::RebuildFiber { .. }
2298 ));
2299 }
2300
2301 #[test]
2302 fn reconcile_isolate_or_intercept_change_rebuilds_fiber() {
2303 let cur = EntryTree(vec![Entry {
2304 id: "a".into(),
2305 plugin: "Foo".into(),
2306 config: json!(null),
2307 disabled: false,
2308 isolate: None,
2309 intercept: HashMap::new(),
2310 position: None,
2311 }]);
2312 let des_isolate = EntryTree(vec![Entry {
2313 id: "a".into(),
2314 plugin: "Foo".into(),
2315 config: json!(null),
2316 disabled: false,
2317 isolate: Some("tenant:acme".into()),
2318 intercept: HashMap::new(),
2319 position: None,
2320 }]);
2321 let loader = Loader::new();
2322 assert!(matches!(
2323 loader.reconcile(&cur, &des_isolate)[0],
2324 LoaderAction::RebuildFiber { .. }
2325 ));
2326
2327 let mut intercept = HashMap::new();
2328 intercept.insert("k".into(), json!(1));
2329 let des_intercept = EntryTree(vec![Entry {
2330 id: "a".into(),
2331 plugin: "Foo".into(),
2332 config: json!(null),
2333 disabled: false,
2334 isolate: None,
2335 intercept,
2336 position: None,
2337 }]);
2338 assert!(matches!(
2339 loader.reconcile(&cur, &des_intercept)[0],
2340 LoaderAction::RebuildFiber { .. }
2341 ));
2342 }
2343
2344 #[test]
2345 fn test_load_from_file() {
2346 let dir = tempfile::tempdir().unwrap();
2347 let path = dir.path().join("entries.toml");
2348 std::fs::write(
2349 &path,
2350 r#"
2351[[entry]]
2352id = "calc"
2353plugin = "CalculatorService"
2354disabled = false
2355
2356[entry.config]
2357
2358[[entry]]
2359id = "events"
2360plugin = "EventsService"
2361disabled = true
2362
2363[entry.config]
2364"#,
2365 )
2366 .unwrap();
2367
2368 let tree = Loader::load_from_file(&path).unwrap();
2369 assert_eq!(tree.0.len(), 2);
2370 assert_eq!(tree.0[0].id, "calc");
2371 assert_eq!(tree.0[0].plugin, "CalculatorService");
2372 assert!(!tree.0[0].disabled);
2373 assert_eq!(tree.0[1].id, "events");
2374 assert!(tree.0[1].disabled);
2375 }
2376
2377 #[test]
2378 fn test_reconcile_from_loaded_file() {
2379 let dir = tempfile::tempdir().unwrap();
2380 let path = dir.path().join("entries.toml");
2381 std::fs::write(
2382 &path,
2383 r#"
2384[[entry]]
2385id = "svc1"
2386plugin = "PluginA"
2387disabled = false
2388
2389[entry.config]
2390"#,
2391 )
2392 .unwrap();
2393
2394 let desired = Loader::load_from_file(&path).unwrap();
2395 let current = EntryTree(vec![]);
2396 let loader = Loader::new();
2397 let actions = loader.reconcile(¤t, &desired);
2398 assert!(!actions.is_empty());
2400 assert!(matches!(actions[0], LoaderAction::Begin { .. }));
2401 }
2402
2403 #[test]
2404 fn loader_journal_upsert_and_get() {
2405 let journal = LoaderJournal::new();
2406 assert!(journal.is_empty());
2407 journal.upsert("svc:alpha", "AlphaService", json!({"v": 1}), Some(7));
2408 assert_eq!(journal.len(), 1);
2409 let rec = journal.get("svc:alpha").expect("record present");
2410 assert_eq!(rec.plugin, "AlphaService");
2411 assert_eq!(rec.config, json!({"v": 1}));
2412 assert_eq!(rec.fiber_id, Some(7));
2413 assert_eq!(rec.generation, 1);
2414 }
2415
2416 #[test]
2417 fn retire_clears_record_and_bumps_generation_tracking() {
2418 let journal = LoaderJournal::new();
2419 journal.upsert("svc:beta", "BetaService", json!({"v": 1}), Some(11));
2420
2421 let removed = journal
2423 .retire("svc:beta")
2424 .expect("record present before retire");
2425 assert_eq!(removed.plugin, "BetaService");
2426 assert!(journal.get("svc:beta").is_none());
2427 assert!(journal.is_empty());
2428
2429 journal.upsert("svc:beta", "BetaService", json!({"v": 2}), Some(12));
2432 let rec = journal.get("svc:beta").unwrap();
2433 assert_eq!(rec.fiber_id, Some(12));
2434 assert_eq!(rec.generation, 1);
2435 }
2436
2437 #[test]
2438 fn update_config_bumps_generation_and_stores_new_config() {
2439 let journal = LoaderJournal::new();
2440 journal.upsert("svc:gamma", "GammaService", json!({"v": 1}), Some(21));
2441 assert_eq!(journal.get("svc:gamma").unwrap().generation, 1);
2442
2443 let updated = journal
2444 .update_config("svc:gamma", json!({"v": 2}), None)
2445 .expect("record exists");
2446 assert_eq!(updated.config, json!({"v": 2}));
2447 assert_eq!(updated.generation, 2);
2448
2449 let rec = journal.get("svc:gamma").unwrap();
2451 assert_eq!(rec.config, json!({"v": 2}));
2452 assert_eq!(rec.generation, 2);
2453 assert_eq!(rec.fiber_id, Some(21));
2455 }
2456
2457 #[test]
2458 fn update_config_missing_id_is_noop() {
2459 let journal = LoaderJournal::new();
2460 assert!(journal
2461 .update_config("svc:ghost", json!({"v": 1}), None)
2462 .is_none());
2463 assert!(journal.is_empty());
2464 }
2465
2466 #[test]
2467 fn execute_action_retire_clears_journal_record() {
2468 let ctx = Context::new_root();
2469 let journal = ctx.provide(LoaderJournal::new());
2470 journal.upsert("svc:delta", "DeltaService", json!({"v": 1}), Some(31));
2471
2472 Loader::execute_action(
2473 &LoaderAction::Retire {
2474 id: "svc:delta".into(),
2475 },
2476 &ctx,
2477 );
2478 assert!(journal.get("svc:delta").is_none());
2479 assert!(journal.is_empty());
2480 }
2481
2482 #[test]
2483 fn execute_action_update_config_bumps_generation_without_fiber() {
2484 let ctx = Context::new_root();
2485 let journal = ctx.provide(LoaderJournal::new());
2486 journal.upsert("svc:epsilon", "EpsilonService", json!({"v": 1}), Some(41));
2487
2488 Loader::execute_action(
2489 &LoaderAction::UpdateConfig {
2490 id: "svc:epsilon".into(),
2491 new_config: json!({"v": 2}),
2492 },
2493 &ctx,
2494 );
2495
2496 let rec = journal.get("svc:epsilon").expect("record retained");
2500 assert_eq!(rec.config, json!({"v": 2}));
2501 assert_eq!(rec.generation, 2);
2502 assert_eq!(rec.fiber_id, Some(41));
2503 }
2504
2505 #[test]
2506 fn execute_action_update_config_without_journal_is_log_only() {
2507 let ctx = Context::new_root();
2508 Loader::execute_action(
2510 &LoaderAction::UpdateConfig {
2511 id: "svc:zeta".into(),
2512 new_config: json!({"v": 2}),
2513 },
2514 &ctx,
2515 );
2516 assert!(ctx.get::<LoaderJournal>().is_none());
2517 }
2518
2519 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2520 async fn instantiate_writes_journal_record_and_update_reaches_live_fiber() {
2521 use crate::RegistryService;
2522
2523 let ctx = Context::new_root();
2524 ctx.provide(LoaderJournal::new());
2525 ctx.provide(RegistryService::new());
2526 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
2527
2528 #[derive(Debug)]
2531 struct Svc;
2532 impl Service for Svc {}
2533
2534 plugin_registry.register(
2535 "SvcFactory",
2536 Arc::new(|ctx, config| {
2537 let _ = config;
2538 let future = ctx.plugin(Svc);
2539 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
2540 }),
2541 );
2542
2543 let fid = Loader::instantiate(&ctx, "SvcFactory", &json!({"v": 1}), "svc:theta")
2544 .expect("instantiate should succeed");
2545 assert!(fid > 0);
2546
2547 let journal = ctx.get::<LoaderJournal>().expect("journal present");
2548 let rec = journal
2549 .get("svc:theta")
2550 .expect("instantiate wrote journal record");
2551 assert_eq!(rec.plugin, "SvcFactory");
2552 assert_eq!(rec.config, json!({"v": 1}));
2553 assert_eq!(rec.fiber_id, Some(fid));
2554 assert_eq!(rec.generation, 1);
2555
2556 Loader::execute_action(
2559 &LoaderAction::UpdateConfig {
2560 id: "svc:theta".into(),
2561 new_config: json!({"v": 2}),
2562 },
2563 &ctx,
2564 );
2565 let rec = journal.get("svc:theta").expect("record retained");
2566 assert_eq!(rec.config, json!({"v": 2}));
2567 assert_eq!(rec.generation, 2);
2568 }
2569
2570 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2571 async fn instantiate_entry_applies_isolate_and_intercept() {
2572 use crate::RegistryService;
2573 use std::any::TypeId;
2574
2575 let ctx = Context::new_root();
2576 ctx.provide(LoaderJournal::new());
2577 ctx.provide(RegistryService::new());
2578 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
2579
2580 #[derive(Debug)]
2581 struct Svc(String);
2582 impl Service for Svc {}
2583
2584 plugin_registry.register(
2585 "SvcFactory",
2586 Arc::new(|ctx, config| {
2587 let label = config
2588 .get("mark")
2589 .and_then(|v| v.as_str())
2590 .unwrap_or("none")
2591 .to_string();
2592 let future = ctx.plugin(Svc(label));
2593 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
2594 }),
2595 );
2596
2597 let mut intercept = HashMap::new();
2598 intercept.insert("timeout".into(), json!(5));
2599 let entry = Entry {
2600 id: "svc:acme".into(),
2601 plugin: "SvcFactory".into(),
2602 config: json!({"mark": "acme"}),
2603 disabled: false,
2604 isolate: Some("tenant:acme".into()),
2605 intercept,
2606 position: None,
2607 };
2608 Loader::instantiate_entry(&ctx, &entry).expect("instantiate_entry");
2609
2610 assert_eq!(
2611 ctx.isolate_label(TypeId::of::<Svc>()).as_deref(),
2612 Some("tenant:acme")
2613 );
2614 let isolated = ctx
2615 .get_isolated::<Svc>("tenant:acme")
2616 .expect("isolated Svc");
2617 assert_eq!(isolated.0, "acme");
2618 assert!(ctx.get::<Svc>().is_some(), "boot get still sees the plugin");
2619 let overlay = ctx.get::<EntryIntercept>().expect("EntryIntercept bound");
2620 assert_eq!(overlay.0.get("timeout"), Some(&json!(5)));
2621 }
2622
2623 #[allow(dead_code)]
2624 fn _assert_exports() {
2625 let _: AppliedAction;
2626 }
2627
2628 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2629 async fn apply_begins_instantiate_and_journals() {
2630 use crate::RegistryService;
2631
2632 let ctx = Context::new_root();
2633 let journal = LoaderJournal::provide_new(&ctx);
2634 ctx.provide(RegistryService::new());
2635 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
2636
2637 #[derive(Debug)]
2638 struct SvcA(u64);
2639 impl Service for SvcA {}
2640 #[derive(Debug)]
2641 struct SvcB(u64);
2642 impl Service for SvcB {}
2643
2644 plugin_registry.register(
2645 "FactoryA",
2646 Arc::new(|ctx, _cfg| {
2647 let future = ctx.plugin(SvcA(0));
2648 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
2649 }),
2650 );
2651 plugin_registry.register(
2652 "FactoryB",
2653 Arc::new(|ctx, _cfg| {
2654 let future = ctx.plugin(SvcB(0));
2655 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
2656 }),
2657 );
2658
2659 let desired = EntryTree(vec![
2660 Entry {
2661 id: "a:one".into(),
2662 plugin: "FactoryA".into(),
2663 config: json!({}),
2664 disabled: false,
2665 isolate: None,
2666 intercept: HashMap::new(),
2667 position: None,
2668 },
2669 Entry {
2670 id: "b:two".into(),
2671 plugin: "FactoryB".into(),
2672 config: json!({}),
2673 disabled: false,
2674 isolate: None,
2675 intercept: HashMap::new(),
2676 position: None,
2677 },
2678 ]);
2679 let mut current = EntryTree(vec![]);
2680
2681 let actions = Loader::apply(&ctx, &mut current, &desired, &journal).await;
2682 assert_eq!(actions.len(), 2);
2683 assert!(actions
2684 .iter()
2685 .all(|a| a.action == "begin" && a.status.is_ok()));
2686 assert_eq!(current.0.len(), 2);
2687 assert!(ctx.get::<SvcA>().is_some());
2688 assert!(ctx.get::<SvcB>().is_some());
2689 let rec_a = journal.get("a:one").expect("journal has a");
2690 assert!(rec_a.fiber_id.is_some());
2691
2692 let desired2 = EntryTree(vec![desired.0[1].clone()]);
2694 let actions = Loader::apply(&ctx, &mut current, &desired2, &journal).await;
2695 assert_eq!(actions[0].action, "retire");
2696 assert_eq!(actions[0].status, Ok(()));
2697 assert!(ctx.get::<SvcA>().is_none(), "retired fiber disposed");
2698 assert!(ctx.get::<SvcB>().is_some(), "kept entry still live");
2699 assert!(journal.get("a:one").is_none());
2700 }
2701
2702 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2703 async fn apply_aborts_on_first_failure_and_rolls_back() {
2704 use crate::RegistryService;
2705
2706 let ctx = Context::new_root();
2711 let journal = LoaderJournal::provide_new(&ctx);
2712 ctx.provide(RegistryService::new());
2713 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
2714
2715 #[derive(Debug)]
2716 struct Good(std::sync::atomic::AtomicU64);
2717 impl Service for Good {}
2718
2719 plugin_registry.register(
2720 "GoodFactory",
2721 Arc::new(|ctx, cfg| {
2722 let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
2723 let future = ctx.plugin(Good(std::sync::atomic::AtomicU64::new(v)));
2724 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
2725 }),
2726 );
2727 plugin_registry.register(
2728 "LateGoodFactory",
2729 Arc::new(|ctx, _cfg| {
2730 let future = ctx.plugin(Good(std::sync::atomic::AtomicU64::new(99)));
2731 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
2732 }),
2733 );
2734 let desired = EntryTree(vec![
2737 Entry {
2738 id: "good:one".into(),
2739 plugin: "GhostFactory".into(),
2740 config: json!({}),
2741 disabled: false,
2742 isolate: None,
2743 intercept: HashMap::new(),
2744 position: None,
2745 },
2746 Entry {
2747 id: "good:two".into(),
2748 plugin: "GoodFactory".into(),
2749 config: json!({"v": 1}),
2750 disabled: false,
2751 isolate: None,
2752 intercept: HashMap::new(),
2753 position: None,
2754 },
2755 ]);
2756 let mut current = EntryTree(vec![]);
2757
2758 let actions = Loader::apply(&ctx, &mut current, &desired, &journal).await;
2761 let failed = actions
2762 .iter()
2763 .find(|a| a.id == "good:one")
2764 .expect("failing entry named in results");
2765 assert!(
2766 failed.status.is_err(),
2767 "unknown factory must fail its action"
2768 );
2769 assert_eq!(actions.len(), 1, "abort-on-first-failure: one outcome only");
2770 assert!(
2771 !actions.iter().any(|a| a.id == "good:two"),
2772 "entries after the failing step are never applied"
2773 );
2774 assert!(
2775 ctx.get::<Good>().is_none(),
2776 "no sibling instantiated when the first step already failed"
2777 );
2778 assert!(
2779 current.0.is_empty(),
2780 "current tree must stay unchanged when any action failed"
2781 );
2782
2783 journal.upsert("seed", "GoodFactory", json!({"v": 0}), None);
2786 let desired_late = EntryTree(vec![
2787 Entry {
2788 id: "good:first".into(),
2789 plugin: "GoodFactory".into(),
2790 config: json!({"v": 7}),
2791 disabled: false,
2792 isolate: None,
2793 intercept: HashMap::new(),
2794 position: None,
2795 },
2796 Entry {
2797 id: "good:last".into(),
2798 plugin: "GhostFactory".into(),
2799 config: json!({}),
2800 disabled: false,
2801 isolate: None,
2802 intercept: HashMap::new(),
2803 position: None,
2804 },
2805 Entry {
2806 id: "good:never".into(),
2807 plugin: "LateGoodFactory".into(),
2808 config: json!({}),
2809 disabled: false,
2810 isolate: None,
2811 intercept: HashMap::new(),
2812 position: None,
2813 },
2814 ]);
2815 let actions =
2816 Loader::apply(&ctx, &mut current, &desired_late, &journal).await;
2817 let failed = actions
2818 .iter()
2819 .find(|a| a.id == "good:last")
2820 .expect("mid-batch failure named");
2821 assert!(failed.status.is_err());
2822 assert!(
2823 failed.status.as_ref().unwrap_err().contains("no factory registered"),
2824 "error names the cause: {:?}",
2825 failed.status
2826 );
2827 assert!(
2828 !actions.iter().any(|a| a.id == "good:never"),
2829 "entries past the failure never ran"
2830 );
2831 assert!(
2832 ctx.get::<Good>().is_none(),
2833 "rolled back: the entry applied before the failure is disposed"
2834 );
2835 assert!(
2836 journal.get("good:first").is_none(),
2837 "rollback retired the began entry's journal record"
2838 );
2839 assert!(
2840 current.0.is_empty(),
2841 "current stays at the prior tree after a rolled-back batch"
2842 );
2843 }
2844
2845 #[derive(Debug)]
2849 struct Swappable(std::sync::atomic::AtomicU64);
2850 impl Service for Swappable {}
2851
2852 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2853 async fn rebuild_same_type_verified_swap() {
2854 use crate::RegistryService;
2855 use std::sync::atomic::Ordering;
2856
2857 let ctx = Context::new_root();
2858 let journal = LoaderJournal::provide_new(&ctx);
2859 ctx.provide(RegistryService::new());
2860 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
2861
2862 plugin_registry.register(
2865 "SwapFactoryA",
2866 Arc::new(|ctx: &Arc<crate::Context>, _cfg| {
2867 let fut = ctx.plugin(Swappable(std::sync::atomic::AtomicU64::new(1)));
2868 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
2869 }),
2870 );
2871 plugin_registry.register(
2872 "SwapFactoryB",
2873 Arc::new(|ctx: &Arc<crate::Context>, _cfg| {
2874 let fut = ctx.plugin(Swappable(std::sync::atomic::AtomicU64::new(2)));
2875 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
2876 }),
2877 );
2878
2879 let desired_a = EntryTree(vec![Entry {
2880 id: "swap".into(),
2881 plugin: "SwapFactoryA".into(),
2882 config: json!({}),
2883 disabled: false,
2884 isolate: None,
2885 intercept: HashMap::new(),
2886 position: None,
2887 }]);
2888 let mut current = EntryTree(vec![]);
2889 let actions = Loader::apply(&ctx, &mut current, &desired_a, &journal).await;
2890 assert_eq!(actions[0].action, "begin");
2891 assert!(actions[0].status.is_ok());
2892 assert_eq!(actions[0].verified, true);
2893 let svc = ctx.get::<Swappable>().expect("initial provider");
2894 assert_eq!(svc.0.load(Ordering::SeqCst), 1);
2895
2896 let desired_b = EntryTree(vec![Entry {
2900 id: "swap".into(),
2901 plugin: "SwapFactoryB".into(),
2902 config: json!({}),
2903 disabled: false,
2904 isolate: None,
2905 intercept: HashMap::new(),
2906 position: None,
2907 }]);
2908 let ctx_probe = ctx.clone();
2909 let prober = tokio::spawn(async move {
2910 for _ in 0..200 {
2911 if ctx_probe.get::<Swappable>().is_none() {
2912 return false;
2913 }
2914 tokio::task::yield_now().await;
2915 }
2916 true
2917 });
2918 let actions = Loader::apply(&ctx, &mut current, &desired_b, &journal).await;
2919 assert_eq!(actions[0].action, "rebuild-fiber");
2920 assert!(actions[0].status.is_ok(), "rebuild ok");
2921 assert_eq!(actions[0].verified, true, "same-type swap must be verified");
2922
2923 let continuous = prober.await.expect("prober task");
2924 assert!(continuous, "service must stay resolvable during swap");
2925
2926 let svc = ctx.get::<Swappable>().expect("swapped provider");
2928 assert_eq!(svc.0.load(Ordering::SeqCst), 2);
2929 let rec = journal.get("swap").expect("journal record");
2930 let fid = rec.fiber_id.expect("fiber recorded");
2931 let registry = ctx.get::<RegistryService>().unwrap();
2932 assert!(matches!(
2933 registry.get_fiber(fid).unwrap().state(),
2934 crate::FiberState::Active { .. }
2935 ));
2936 assert_eq!(current.0.len(), 1, "current tree advanced");
2937 }
2938
2939 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2943 async fn bad_config_update_keeps_old_provider_serving() {
2944 use crate::RegistryService;
2945 use std::sync::atomic::Ordering;
2946
2947 let ctx = Context::new_root();
2948 let journal = LoaderJournal::provide_new(&ctx);
2949 ctx.provide(RegistryService::new());
2950 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
2951
2952 plugin_registry.register(
2955 "PickyFactory",
2956 Arc::new(|ctx: &Arc<crate::Context>, cfg| {
2957 if cfg.get("fail").and_then(|x| x.as_bool()) == Some(true) {
2958 return Err(crate::CordisError::Configuration(
2959 "config rejected by factory".into(),
2960 ));
2961 }
2962 let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
2963 let fut = ctx.plugin(Swappable(std::sync::atomic::AtomicU64::new(v)));
2964 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
2965 }),
2966 );
2967
2968 let entry_ok = Entry {
2969 id: "picky".into(),
2970 plugin: "PickyFactory".into(),
2971 config: json!({"v": 1}),
2972 disabled: false,
2973 isolate: None,
2974 intercept: HashMap::new(),
2975 position: None,
2976 };
2977 let mut current = EntryTree(vec![]);
2978 Loader::apply(&ctx, &mut current, &EntryTree(vec![entry_ok]), &journal).await;
2979 let before = journal.get("picky").expect("journal record after begin");
2980 assert_eq!(
2981 ctx.get::<Swappable>().unwrap().0.load(Ordering::SeqCst),
2982 1,
2983 "old provider serving"
2984 );
2985
2986 let desired_bad = EntryTree(vec![Entry {
2989 id: "picky".into(),
2990 plugin: "PickyFactory".into(),
2991 config: json!({"fail": true}),
2992 disabled: false,
2993 isolate: None,
2994 intercept: HashMap::new(),
2995 position: None,
2996 }]);
2997 let actions = Loader::apply(&ctx, &mut current, &desired_bad, &journal).await;
2998 assert_eq!(actions[0].action, "update-config");
2999 assert!(actions[0].status.is_err(), "pre-flight failure reported");
3000 assert!(
3001 actions[0]
3002 .status
3003 .as_ref()
3004 .unwrap_err()
3005 .contains("config pre-flight failed"),
3006 "failure names the pre-flight marker, got {:?}",
3007 actions[0].status
3008 );
3009
3010 assert!(
3013 ctx.get::<Swappable>().is_some(),
3014 "old provider kept serving"
3015 );
3016 assert_eq!(
3017 ctx.get::<Swappable>().unwrap().0.load(Ordering::SeqCst),
3018 1,
3019 "still the OLD instance value"
3020 );
3021 let after = journal.get("picky").expect("record retained");
3022 assert_eq!(after.generation, before.generation, "generation frozen");
3023 assert_eq!(after.config, json!({"v": 1}), "config not overwritten");
3024 let fid = before.fiber_id.expect("fiber tracked");
3025 assert!(matches!(
3026 ctx.get::<RegistryService>()
3027 .unwrap()
3028 .get_fiber(fid)
3029 .unwrap()
3030 .state(),
3031 crate::FiberState::Active { .. }
3032 ));
3033 assert_eq!(current.0[0].config, json!({"v": 1}), "tree unchanged");
3034
3035 let desired_good = EntryTree(vec![Entry {
3038 id: "picky".into(),
3039 plugin: "PickyFactory".into(),
3040 config: json!({"v": 5}),
3041 disabled: false,
3042 isolate: None,
3043 intercept: HashMap::new(),
3044 position: None,
3045 }]);
3046 let actions = Loader::apply(&ctx, &mut current, &desired_good, &journal).await;
3047 assert_eq!(actions[0].action, "update-config");
3048 assert!(actions[0].status.is_ok(), "healthy update applies");
3049 assert_eq!(current.0[0].config, json!({"v": 5}), "tree advanced");
3050 assert_eq!(
3051 journal.get("picky").unwrap().generation,
3052 before.generation + 1,
3053 "journal bumped once on success"
3054 );
3055 }
3056
3057 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3058 async fn rebuild_failure_keeps_old() {
3059 use crate::RegistryService;
3060
3061 let ctx = Context::new_root();
3062 let journal = LoaderJournal::provide_new(&ctx);
3063 ctx.provide(RegistryService::new());
3064 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3065
3066 #[derive(Debug)]
3067 struct Keeper(u64);
3068 impl Service for Keeper {}
3069
3070 plugin_registry.register(
3071 "KeeperFactory",
3072 Arc::new(|ctx: &Arc<crate::Context>, _cfg| {
3073 let fut = ctx.plugin(Keeper(1));
3074 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
3075 }),
3076 );
3077 plugin_registry.register(
3078 "BrokenFactory",
3079 Arc::new(|_ctx: &Arc<crate::Context>, _cfg| {
3080 Err(crate::CordisError::Configuration(
3081 "intentional swap failure".into(),
3082 ))
3083 }),
3084 );
3085
3086 let desired_ok = EntryTree(vec![Entry {
3087 id: "keep".into(),
3088 plugin: "KeeperFactory".into(),
3089 config: json!({}),
3090 disabled: false,
3091 isolate: None,
3092 intercept: HashMap::new(),
3093 position: None,
3094 }]);
3095 let mut current = EntryTree(vec![]);
3096 Loader::apply(&ctx, &mut current, &desired_ok, &journal).await;
3097 assert!(ctx.get::<Keeper>().is_some(), "old provider live");
3098
3099 let desired_bad = EntryTree(vec![Entry {
3101 id: "keep".into(),
3102 plugin: "BrokenFactory".into(),
3103 config: json!({}),
3104 disabled: false,
3105 isolate: None,
3106 intercept: HashMap::new(),
3107 position: None,
3108 }]);
3109 let actions = Loader::apply(&ctx, &mut current, &desired_bad, &journal).await;
3110 assert_eq!(actions[0].action, "rebuild-fiber");
3111 assert!(actions[0].status.is_err(), "failed trial reported");
3112 assert!(actions[0]
3113 .status
3114 .as_ref()
3115 .unwrap_err()
3116 .contains("intentional swap failure"));
3117 assert!(
3118 ctx.get::<Keeper>().is_some(),
3119 "old fiber still Active after failed rebuild"
3120 );
3121 assert_eq!(current.0[0].plugin, "KeeperFactory");
3123 }
3124
3125 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3126 async fn rebuild_without_tracked_fiber_reports_unverified() {
3127 use crate::RegistryService;
3128
3129 let ctx = Context::new_root();
3132 let journal = LoaderJournal::provide_new(&ctx);
3133 ctx.provide(RegistryService::new());
3134 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3135
3136 #[derive(Debug)]
3137 struct Fallback(u64);
3138 impl Service for Fallback {}
3139
3140 plugin_registry.register(
3141 "FallbackFactory",
3142 Arc::new(|ctx: &Arc<crate::Context>, _cfg| {
3143 let fut = ctx.plugin(Fallback(9));
3144 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
3145 }),
3146 );
3147 journal.upsert("fb", "FallbackFactory", json!({}), None);
3150
3151 let desired = EntryTree(vec![Entry {
3152 id: "fb".into(),
3153 plugin: "FallbackFactory".into(),
3154 config: json!({}),
3155 disabled: false,
3156 isolate: None,
3157 intercept: HashMap::new(),
3158 position: None,
3159 }]);
3160 let mut current = EntryTree(vec![Entry {
3161 id: "fb".into(),
3162 plugin: "OtherPlugin".into(),
3163 config: json!({}),
3164 disabled: false,
3165 isolate: None,
3166 intercept: HashMap::new(),
3167 position: None,
3168 }]);
3169 let actions = Loader::apply(&ctx, &mut current, &desired, &journal).await;
3170 assert_eq!(actions[0].action, "rebuild-fiber");
3171 assert!(actions[0].status.is_ok());
3172 assert_eq!(actions[0].verified, false, "fallback is unverified");
3173 assert!(ctx.get::<Fallback>().is_some(), "entry instantiated");
3174 }
3175
3176 #[test]
3177 fn save_to_toml_file_round_trips_entries() {
3178 let dir = tempfile::tempdir().unwrap();
3179 let path = dir.path().join("entries.toml");
3180 let tree = EntryTree(vec![
3181 Entry {
3182 id: "tool:calc".into(),
3183 plugin: "CalculatorService".into(),
3184 config: json!({"precision": 2}),
3185 disabled: true,
3186 isolate: None,
3187 intercept: HashMap::new(),
3188 position: None,
3189 },
3190 Entry {
3191 id: "svc:acme".into(),
3192 plugin: "PluginA".into(),
3193 config: json!({"x": 1}),
3194 disabled: false,
3195 isolate: Some("acme".into()),
3196 intercept: HashMap::new(),
3197 position: None,
3198 },
3199 ]);
3200 tree.save_to_toml_file(&path).unwrap();
3201 let loaded = Loader::load_from_file(&path).unwrap();
3202 assert_eq!(tree, loaded);
3203 }
3204
3205 #[test]
3206 fn save_to_toml_file_leaves_no_temp_files() {
3207 let dir = tempfile::tempdir().unwrap();
3208 let path = dir.path().join("entries.toml");
3209 let tree = EntryTree(vec![Entry {
3210 id: "tool:calc".into(),
3211 plugin: "CalculatorService".into(),
3212 config: json!({}),
3213 disabled: false,
3214 isolate: None,
3215 intercept: HashMap::new(),
3216 position: None,
3217 }]);
3218 tree.save_to_toml_file(&path).unwrap();
3221 tree.save_to_toml_file(&path).unwrap();
3222 let mut leftovers: Vec<String> = std::fs::read_dir(dir.path())
3223 .unwrap()
3224 .filter_map(Result::ok)
3225 .map(|e| e.file_name().to_string_lossy().into_owned())
3226 .collect();
3227 leftovers.sort();
3228 assert_eq!(leftovers, vec!["entries.toml".to_string()]);
3229 }
3230
3231 #[test]
3232 fn save_to_toml_file_preserves_comment_header() {
3233 let dir = tempfile::tempdir().unwrap();
3234 let path = dir.path().join("entries.toml");
3235 std::fs::write(
3236 &path,
3237 r#"# Cordis plugin entries loaded at startup.
3238# Order matters.
3239
3240[[entry]]
3241id = "a"
3242plugin = "Foo"
3243
3244[entry.config]
3245
3246[[entry]]
3247id = "b"
3248plugin = "Bar"
3249
3250[entry.config]
3251"#,
3252 )
3253 .unwrap();
3254
3255 let mut tree = Loader::load_from_file(&path).unwrap();
3256 assert_eq!(tree.len(), 2);
3257 tree.0.push(Entry {
3258 id: "c".into(),
3259 plugin: "Baz".into(),
3260 config: json!({}),
3261 disabled: false,
3262 isolate: Some("acme".into()),
3263 intercept: HashMap::new(),
3264 position: None,
3265 });
3266 tree.save_to_toml_file(&path).unwrap();
3267
3268 let raw = std::fs::read_to_string(&path).unwrap();
3269 let first_table = raw.find("[[entry]]").expect("serialized body present");
3270 let header = &raw[..first_table];
3271 assert!(
3272 header.contains("# Cordis plugin entries loaded at startup."),
3273 "first comment line must survive the round-trip"
3274 );
3275 assert!(
3276 header.contains("# Order matters."),
3277 "second comment line must survive the round-trip"
3278 );
3279
3280 let reloaded = Loader::load_from_file(&path).unwrap();
3281 assert_eq!(reloaded.len(), 3);
3282 assert_eq!(reloaded, tree);
3283 }
3284
3285 #[test]
3286 fn save_to_toml_file_empty_tree_writes_valid_toml() {
3287 let dir = tempfile::tempdir().unwrap();
3288 let path = dir.path().join("empty.toml");
3289 EntryTree::default().save_to_toml_file(&path).unwrap();
3290 let loaded = Loader::load_from_file(&path).unwrap();
3291 assert_eq!(loaded.len(), 0);
3292 assert!(loaded.is_empty());
3293 }
3294
3295 #[test]
3296 fn save_to_file_is_atomic_no_temp_residue() {
3297 let dir = tempfile::tempdir().unwrap();
3298 let path = dir.path().join("nested").join("entries.json");
3299 let tree = EntryTree(vec![Entry {
3300 id: "tool:calc".into(),
3301 plugin: "CalculatorService".into(),
3302 config: json!({"precision": 2}),
3303 disabled: false,
3304 isolate: None,
3305 intercept: HashMap::new(),
3306 position: None,
3307 }]);
3308 tree.save_to_file(path.to_str().unwrap()).unwrap();
3310 assert_eq!(
3311 EntryTree::load_from_file(path.to_str().unwrap()).unwrap(),
3312 tree,
3313 "content survives the temp+rename round-trip"
3314 );
3315 tree.save_to_file(path.to_str().unwrap()).unwrap();
3318 let mut leftovers: Vec<String> = std::fs::read_dir(dir.path())
3319 .unwrap()
3320 .filter_map(Result::ok)
3321 .map(|e| e.file_name().to_string_lossy().into_owned())
3322 .collect();
3323 leftovers.sort();
3324 assert_eq!(
3325 leftovers,
3326 vec!["nested".to_string()],
3327 "no *.tmp-* siblings may remain after a successful save"
3328 );
3329 let inner: Vec<String> = std::fs::read_dir(dir.path().join("nested"))
3330 .unwrap()
3331 .filter_map(Result::ok)
3332 .map(|e| e.file_name().to_string_lossy().into_owned())
3333 .collect();
3334 assert_eq!(inner, vec!["entries.json".to_string()]);
3335 }
3336
3337 #[test]
3338 fn save_to_file_consecutive_saves_succeed_with_distinct_temps() {
3339 let dir = tempfile::tempdir().unwrap();
3340 let path = dir.path().join("entries.json");
3341 let tree = EntryTree::default();
3342 tree.save_to_file(path.to_str().unwrap()).unwrap();
3346 tree.save_to_file(path.to_str().unwrap()).unwrap();
3347 assert_eq!(
3348 EntryTree::load_from_file(path.to_str().unwrap()).unwrap(),
3349 EntryTree::default()
3350 );
3351 let a = next_save_nonce();
3353 let b = next_save_nonce();
3354 assert_ne!(a, b, "nonce must be monotonic across calls");
3355 }
3356
3357 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3363 async fn cycle_detection_finds_mutual_declared_injects() {
3364 use crate::cycles::CycleLedger;
3365 use crate::{Plugin, RegistryService};
3366
3367 #[derive(Debug)]
3368 struct SvcA(u32);
3369 impl Service for SvcA {}
3370 #[derive(Debug)]
3371 struct SvcB(u32);
3372 impl Service for SvcB {}
3373
3374 struct PluginA;
3375 impl Plugin for PluginA {
3376 type Config = ();
3377 type Provides = SvcA;
3378 fn apply(&self, ctx: &Arc<Context>, _cfg: ()) -> Result<Arc<SvcA>, crate::CordisError> {
3379 Ok(ctx.provide(SvcA(1)))
3380 }
3381 }
3382
3383 struct PluginB;
3384 impl Plugin for PluginB {
3385 type Config = ();
3386 type Provides = SvcB;
3387 fn apply(&self, ctx: &Arc<Context>, _cfg: ()) -> Result<Arc<SvcB>, crate::CordisError> {
3388 Ok(ctx.provide(SvcB(2)))
3389 }
3390 }
3391
3392 let ctx = Context::new_root();
3393 ctx.provide(crate::LoaderJournal::new());
3394 ctx.provide(RegistryService::new());
3395 ctx.provide(CycleLedger::new());
3396 let registry = ctx.get::<RegistryService>().unwrap();
3397
3398 let fid_a = registry.plugin(&ctx, PluginA, ()).expect("register A");
3399 let fid_b = registry.plugin(&ctx, PluginB, ()).expect("register B");
3400 let ledger = ctx.get::<CycleLedger>().unwrap();
3403 ledger.record_provider(std::any::TypeId::of::<SvcA>(), None, fid_a);
3404 ledger.record_provider(std::any::TypeId::of::<SvcB>(), None, fid_b);
3405 registry.get_fiber(fid_a).unwrap().declare_inject::<SvcB>();
3408 registry.get_fiber(fid_b).unwrap().declare_inject::<SvcA>();
3409
3410 let cycles = Loader::detect_cycles(&ctx);
3411 assert_eq!(cycles.len(), 1, "exactly one 2-cycle expected");
3412 let cycle = &cycles[0];
3413 assert_eq!(cycle.len(), 3, "closed ring: [x, y, x]");
3414 assert_eq!(cycle[0], cycle[2], "ring closes on itself");
3415
3416 let journal = ctx.get::<crate::LoaderJournal>().unwrap();
3419 journal.upsert("a", "PluginA", json!({}), Some(fid_a));
3420 journal.upsert("b", "PluginB", json!({}), Some(fid_b));
3421 let ids = Loader::cycle_entry_ids(Some(journal.as_ref()), &cycles);
3422 assert_eq!(
3423 ids,
3424 vec![vec!["a".to_string(), "b".to_string(), "a".to_string()]]
3425 );
3426 }
3427
3428 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3432 async fn apply_reports_cycle_without_failing_batch() {
3433 use crate::cycles::CycleLedger;
3434 use crate::{Plugin, RegistryService};
3435
3436 #[derive(Debug)]
3437 struct SvcA(u32);
3438 impl Service for SvcA {}
3439 #[derive(Debug)]
3440 struct SvcB(u32);
3441 impl Service for SvcB {}
3442
3443 struct PluginA;
3444 impl Plugin for PluginA {
3445 type Config = serde_json::Value;
3446 type Provides = SvcA;
3447 fn apply(
3448 &self,
3449 ctx: &Arc<Context>,
3450 _cfg: serde_json::Value,
3451 ) -> Result<Arc<SvcA>, crate::CordisError> {
3452 Ok(ctx.provide(SvcA(1)))
3453 }
3454 }
3455
3456 struct PluginB;
3457 impl Plugin for PluginB {
3458 type Config = serde_json::Value;
3459 type Provides = SvcB;
3460 fn apply(
3461 &self,
3462 ctx: &Arc<Context>,
3463 _cfg: serde_json::Value,
3464 ) -> Result<Arc<SvcB>, crate::CordisError> {
3465 Ok(ctx.provide(SvcB(2)))
3466 }
3467 }
3468
3469 let ctx = Context::new_root();
3470 let journal = ctx.provide(crate::LoaderJournal::new());
3471 ctx.provide(RegistryService::new());
3472 ctx.provide(CycleLedger::new());
3473 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3474
3475 plugin_registry.register(
3476 "CycleA",
3477 Arc::new(|ctx, _config| {
3478 let future = ctx.plugin(SvcA(1));
3479 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
3480 }),
3481 );
3482 plugin_registry.register(
3483 "CycleB",
3484 Arc::new(|ctx, _config| {
3485 let future = ctx.plugin(SvcB(2));
3486 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
3487 }),
3488 );
3489
3490 let entry_a = Entry {
3491 id: "cyc:a".into(),
3492 plugin: "CycleA".into(),
3493 config: json!({}),
3494 disabled: false,
3495 isolate: None,
3496 intercept: HashMap::new(),
3497 position: None,
3498 };
3499 let mut entry_b = entry_a.clone();
3500 entry_b.id = "cyc:b".into();
3501 entry_b.plugin = "CycleB".into();
3502
3503 let desired = EntryTree(vec![entry_a.clone(), entry_b.clone()]);
3504 let mut current = EntryTree::default();
3505 let actions = Loader::apply(&ctx, &mut current, &desired, &journal).await;
3506 assert!(
3507 actions.iter().all(|a| a.status.is_ok()),
3508 "apply must not fail because of the cycle: {actions:?}"
3509 );
3510 assert_eq!(current.0.len(), 2, "tree advanced despite the cycle");
3511
3512 let fid_a = journal.get("cyc:a").unwrap().fiber_id.unwrap();
3516 let fid_b = journal.get("cyc:b").unwrap().fiber_id.unwrap();
3517 ctx.get::<RegistryService>()
3518 .unwrap()
3519 .get_fiber(fid_a)
3520 .unwrap()
3521 .declare_inject::<SvcB>();
3522 ctx.get::<RegistryService>()
3523 .unwrap()
3524 .get_fiber(fid_b)
3525 .unwrap()
3526 .declare_inject::<SvcA>();
3527
3528 let cycles = Loader::detect_cycles(&ctx);
3529 assert_eq!(cycles.len(), 1);
3530 let ring: std::collections::HashSet<u64> = cycles[0].iter().copied().collect();
3534 let expected: std::collections::HashSet<u64> = [fid_a, fid_b].into_iter().collect();
3535 assert_eq!(ring, expected, "closed 2-ring over both fibers");
3536 }
3537
3538 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3541 async fn replace_provider_zero_absence_window() {
3542 use crate::RegistryService;
3543 use std::sync::atomic::Ordering;
3544
3545 let ctx = Context::new_root();
3546 let journal = LoaderJournal::provide_new(&ctx);
3547 ctx.provide(RegistryService::new());
3548 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3549
3550 plugin_registry.register(
3551 "SwapFactoryA",
3552 Arc::new(|ctx: &Arc<crate::Context>, _cfg| {
3553 let fut = ctx.plugin(Swappable(std::sync::atomic::AtomicU64::new(1)));
3554 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
3555 }),
3556 );
3557 plugin_registry.register(
3558 "SwapFactory",
3559 Arc::new(|ctx: &Arc<crate::Context>, cfg| {
3560 let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
3564 let fut = ctx.plugin(Swappable(std::sync::atomic::AtomicU64::new(v)));
3565 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
3566 }),
3567 );
3568
3569 let entry_a = Entry {
3570 id: "swap".into(),
3571 plugin: "SwapFactory".into(),
3572 config: json!({"v": 1}),
3573 disabled: false,
3574 isolate: None,
3575 intercept: HashMap::new(),
3576 position: None,
3577 };
3578 let mut current = EntryTree(vec![]);
3579 Loader::apply(&ctx, &mut current, &EntryTree(vec![entry_a]), &journal).await;
3580 let old_rec = journal.get("swap").expect("journal record after begin");
3581 let old_fid = old_rec.fiber_id.expect("fiber tracked");
3582 let old_gen = old_rec.generation;
3583 assert_eq!(
3584 ctx.get::<Swappable>().unwrap().0.load(Ordering::SeqCst),
3585 1,
3586 "old provider serving"
3587 );
3588
3589 let ctx_probe = ctx.clone();
3592 let prober = tokio::spawn(async move {
3593 for _ in 0..300 {
3594 if ctx_probe.get::<Swappable>().is_none() {
3595 return false;
3596 }
3597 tokio::task::yield_now().await;
3598 }
3599 true
3600 });
3601
3602 let loader = Loader::new();
3603 let new_fid = loader
3604 .replace_provider(&ctx, "SwapFactory", json!({"v": 2}), &journal)
3605 .await
3606 .expect("replace_provider swap");
3607
3608 let continuous = prober.await.expect("prober task");
3609 assert!(continuous, "get must stay satisfied during the whole swap");
3610
3611 let svc = ctx.get::<Swappable>().expect("swapped provider");
3613 assert_eq!(svc.0.load(Ordering::SeqCst), 2, "instance flipped");
3614 let registry = ctx.get::<RegistryService>().unwrap();
3615 assert!(matches!(
3616 registry
3617 .get_fiber(new_fid)
3618 .expect("new fiber tracked")
3619 .state(),
3620 crate::FiberState::Active { .. }
3621 ));
3622 assert!(
3623 registry.get_fiber(old_fid).is_none(),
3624 "old registration removed"
3625 );
3626 let rec = journal.get("swap").expect("journal record after replace");
3628 assert_eq!(rec.fiber_id, Some(new_fid));
3629 assert_eq!(rec.generation, old_gen + 1);
3630 assert_ne!(rec.fiber_id, Some(old_fid));
3631 }
3632
3633 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3634 async fn replace_provider_failure_keeps_old() {
3635 use crate::RegistryService;
3636
3637 #[derive(Debug)]
3638 struct Keeper(u64);
3639 impl Service for Keeper {}
3640
3641 let ctx = Context::new_root();
3642 let journal = LoaderJournal::provide_new(&ctx);
3643 ctx.provide(RegistryService::new());
3644 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3645
3646 plugin_registry.register(
3647 "KeeperFactory",
3648 Arc::new(|ctx: &Arc<crate::Context>, cfg| {
3649 if cfg.get("fail").and_then(|x| x.as_bool()) == Some(true) {
3654 return Err(crate::CordisError::Configuration(
3655 "intentional replace failure".into(),
3656 ));
3657 }
3658 let fut = ctx.plugin(Keeper(1));
3659 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
3660 }),
3661 );
3662
3663 let entry_ok = Entry {
3664 id: "keep".into(),
3665 plugin: "KeeperFactory".into(),
3666 config: json!({}),
3667 disabled: false,
3668 isolate: None,
3669 intercept: HashMap::new(),
3670 position: None,
3671 };
3672 let mut current = EntryTree(vec![]);
3673 Loader::apply(&ctx, &mut current, &EntryTree(vec![entry_ok]), &journal).await;
3674 let before = journal.get("keep").expect("journal record");
3675 let old_fid = before.fiber_id.expect("old fiber tracked");
3676
3677 let loader = Loader::new();
3678 let err = loader
3679 .replace_provider(&ctx, "KeeperFactory", json!({"fail": true}), &journal)
3680 .await
3681 .expect_err("failing trial must error");
3682 assert!(
3683 err.to_string().contains("intentional replace failure"),
3684 "error carries the factory failure: {err}"
3685 );
3686
3687 assert!(
3690 ctx.get::<Keeper>().is_some(),
3691 "old provider kept after failed replace"
3692 );
3693 let registry = ctx.get::<RegistryService>().unwrap();
3694 assert!(registry.get_fiber(old_fid).is_some(), "old fiber tracked");
3695 assert!(
3696 !matches!(
3697 registry.get_fiber(old_fid).unwrap().state(),
3698 crate::FiberState::Failed { .. }
3699 ),
3700 "old fiber untouched by the failed trial"
3701 );
3702 let after = journal.get("keep").expect("journal record retained");
3703 assert_eq!(after.generation, before.generation, "generation frozen");
3704 assert_eq!(after.fiber_id, Some(old_fid), "fiber id unchanged");
3705 assert!(
3706 !current.0.is_empty() && current.0[0].plugin == "KeeperFactory",
3707 "current tree unchanged"
3708 );
3709 }
3710
3711 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3712 async fn replace_provider_updates_journal() {
3713 use crate::RegistryService;
3714
3715 let ctx = Context::new_root();
3716 let journal = LoaderJournal::provide_new(&ctx);
3717 ctx.provide(RegistryService::new());
3718 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3719
3720 plugin_registry.register(
3721 "SwapFactory",
3722 Arc::new(|ctx: &Arc<crate::Context>, cfg| {
3723 let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
3724 let fut = ctx.plugin(Swappable(std::sync::atomic::AtomicU64::new(v)));
3725 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
3726 }),
3727 );
3728
3729 let entry_a = Entry {
3730 id: "svc:swap".into(),
3731 plugin: "SwapFactory".into(),
3732 config: json!({"v": 1}),
3733 disabled: false,
3734 isolate: None,
3735 intercept: HashMap::new(),
3736 position: None,
3737 };
3738 let mut current = EntryTree(vec![]);
3739 Loader::apply(&ctx, &mut current, &EntryTree(vec![entry_a]), &journal).await;
3740 let before = journal.get("svc:swap").expect("record present");
3741 assert_eq!(before.generation, 1);
3742 assert_eq!(before.config, json!({"v": 1}));
3743
3744 let loader = Loader::new();
3745 let new_config = json!({"v": 7});
3746 let new_fid = loader
3747 .replace_provider(&ctx, "SwapFactory", new_config.clone(), &journal)
3748 .await
3749 .expect("replace ok");
3750
3751 let rec = journal.get("svc:swap").expect("record retained");
3752 assert_eq!(
3753 rec.fiber_id,
3754 Some(new_fid),
3755 "new fiber id recorded in the journal"
3756 );
3757 assert_ne!(rec.fiber_id, before.fiber_id, "fiber id flipped");
3758 assert_eq!(
3759 rec.generation,
3760 before.generation + 1,
3761 "generation bumped exactly once per successful replace"
3762 );
3763 assert_eq!(rec.config, new_config, "new config stored on the record");
3764 assert_eq!(rec.plugin, "SwapFactory", "plugin label retained");
3765 let svc = ctx.get::<Swappable>().expect("swapped provider");
3767 assert_eq!(svc.0.load(std::sync::atomic::Ordering::SeqCst), 7);
3768
3769 let again = loader
3772 .replace_provider(&ctx, "SwapFactory", json!({"v": 8}), &journal)
3773 .await
3774 .expect("self-replace ok");
3775 let rec2 = journal.get("svc:swap").expect("record retained");
3776 assert_eq!(rec2.fiber_id, Some(again));
3777 assert_eq!(rec2.generation, rec.generation + 1);
3778 assert_eq!(
3779 ctx.get::<Swappable>()
3780 .unwrap()
3781 .0
3782 .load(std::sync::atomic::Ordering::SeqCst),
3783 8,
3784 "second swap live"
3785 );
3786 }
3787
3788 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3796 async fn config_only_change_patches_without_restart() {
3797 use crate::RegistryService;
3798 use std::sync::atomic::Ordering;
3799
3800 let ctx = Context::new_root();
3801 let journal = LoaderJournal::provide_new(&ctx);
3802 let ops = ctx.provide(LoaderOps::new());
3803 ctx.provide(RegistryService::new());
3804 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3805
3806 plugin_registry.register(
3807 "PickyFactory",
3808 Arc::new(|ctx: &Arc<crate::Context>, cfg| {
3809 if cfg.get("fail").and_then(|x| x.as_bool()) == Some(true) {
3810 return Err(crate::CordisError::Configuration(
3811 "config rejected by factory".into(),
3812 ));
3813 }
3814 let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
3815 let fut = ctx.plugin(Swappable(std::sync::atomic::AtomicU64::new(v)));
3816 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
3817 }),
3818 );
3819
3820 let mut current = EntryTree(vec![]);
3821 Loader::apply(
3822 &ctx,
3823 &mut current,
3824 &EntryTree(vec![Entry {
3825 id: "picky".into(),
3826 plugin: "PickyFactory".into(),
3827 config: json!({"v": 1}),
3828 disabled: false,
3829 isolate: None,
3830 intercept: HashMap::new(),
3831 position: None,
3832 }]),
3833 &journal,
3834 )
3835 .await;
3836 let fid = journal.get("picky").unwrap().fiber_id.unwrap();
3837
3838 let actions = Loader::apply(
3840 &ctx,
3841 &mut current,
3842 &EntryTree(vec![Entry {
3843 id: "picky".into(),
3844 plugin: "PickyFactory".into(),
3845 config: json!({"v": 5}),
3846 disabled: false,
3847 isolate: None,
3848 intercept: HashMap::new(),
3849 position: None,
3850 }]),
3851 &journal,
3852 )
3853 .await;
3854 assert_eq!(actions[0].action, "update-config");
3855 assert!(actions[0].status.is_ok(), "{:?}", actions[0].status);
3856
3857 assert_eq!(
3862 ctx.get::<Swappable>().unwrap().0.load(Ordering::SeqCst),
3863 1,
3864 "same live instance kept serving (no restart)"
3865 );
3866 assert_eq!(journal.get("picky").unwrap().fiber_id, Some(fid));
3867 assert_eq!(journal.get("picky").unwrap().config, json!({"v": 5}));
3868 assert_eq!(
3871 ops.apply_count("picky"),
3872 1,
3873 "config-only patch must not re-invoke the entry's Begin"
3874 );
3875 }
3876
3877 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3880 async fn rejected_patch_keeps_old_config() {
3881 use crate::RegistryService;
3882 use std::sync::atomic::Ordering;
3883
3884 let ctx = Context::new_root();
3885 let journal = LoaderJournal::provide_new(&ctx);
3886 ctx.provide(RegistryService::new());
3887 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3888
3889 plugin_registry.register(
3890 "PickyFactory",
3891 Arc::new(|ctx: &Arc<crate::Context>, cfg| {
3892 if cfg.get("fail").and_then(|x| x.as_bool()) == Some(true) {
3893 return Err(crate::CordisError::Configuration(
3894 "config rejected by factory".into(),
3895 ));
3896 }
3897 let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
3898 let fut = ctx.plugin(Swappable(std::sync::atomic::AtomicU64::new(v)));
3899 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
3900 }),
3901 );
3902
3903 let mut current = EntryTree(vec![]);
3904 Loader::apply(
3905 &ctx,
3906 &mut current,
3907 &EntryTree(vec![Entry {
3908 id: "picky".into(),
3909 plugin: "PickyFactory".into(),
3910 config: json!({"v": 1}),
3911 disabled: false,
3912 isolate: None,
3913 intercept: HashMap::new(),
3914 position: None,
3915 }]),
3916 &journal,
3917 )
3918 .await;
3919 let before = journal.get("picky").expect("record");
3920
3921 let actions = Loader::apply(
3922 &ctx,
3923 &mut current,
3924 &EntryTree(vec![Entry {
3925 id: "picky".into(),
3926 plugin: "PickyFactory".into(),
3927 config: json!({"fail": true}),
3928 disabled: false,
3929 isolate: None,
3930 intercept: HashMap::new(),
3931 position: None,
3932 }]),
3933 &journal,
3934 )
3935 .await;
3936 assert_eq!(actions[0].action, "update-config");
3937 let err = actions[0].status.as_ref().unwrap_err();
3938 assert!(err.contains("config pre-flight failed"), "{err}");
3939
3940 assert_eq!(
3942 ctx.get::<Swappable>().unwrap().0.load(Ordering::SeqCst),
3943 1,
3944 "old instance still serving"
3945 );
3946 assert_eq!(
3947 journal.get("picky").unwrap().config,
3948 json!({"v": 1}),
3949 "journal kept the old config"
3950 );
3951 assert_eq!(journal.get("picky").unwrap().generation, before.generation);
3952 assert_eq!(current.0[0].config, json!({"v": 1}), "tree unchanged");
3953
3954 let actions = Loader::apply(
3958 &ctx,
3959 &mut current,
3960 &EntryTree(vec![Entry {
3961 id: "picky".into(),
3962 plugin: "PickyFactory".into(),
3963 config: json!({"v": 2}),
3964 disabled: false,
3965 isolate: None,
3966 intercept: HashMap::new(),
3967 position: None,
3968 }]),
3969 &journal,
3970 )
3971 .await;
3972 assert!(actions[0].status.is_ok(), "{:?}", actions[0].status);
3973 assert_eq!(current.0[0].config, json!({"v": 2}), "tree advanced");
3974 assert_eq!(
3975 journal.get("picky").unwrap().generation,
3976 before.generation + 1,
3977 "exactly one successful journal bump"
3978 );
3979 }
3980
3981 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3985 async fn staged_batch_rolls_back_on_first_failure() {
3986 use crate::RegistryService;
3987 use std::sync::atomic::{AtomicU64, Ordering};
3988
3989 let ctx = Context::new_root();
3990 let journal = LoaderJournal::provide_new(&ctx);
3991 ctx.provide(RegistryService::new());
3992 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
3993
3994 #[derive(Debug)]
3995 struct Triple(AtomicU64);
3996 impl Service for Triple {}
3997
3998 plugin_registry.register(
3999 "TripleFactory",
4000 Arc::new(|ctx: &Arc<crate::Context>, cfg| {
4001 let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
4002 let fut = ctx.plugin(Triple(AtomicU64::new(v)));
4003 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
4004 }),
4005 );
4006
4007 let mut current = EntryTree(vec![]);
4010 Loader::apply(
4011 &ctx,
4012 &mut current,
4013 &EntryTree(vec![Entry {
4014 id: "t:live".into(),
4015 plugin: "TripleFactory".into(),
4016 config: json!({"v": 100}),
4017 disabled: false,
4018 isolate: None,
4019 intercept: HashMap::new(),
4020 position: None,
4021 }]),
4022 &journal,
4023 )
4024 .await;
4025 let live_fid = journal.get("t:live").unwrap().fiber_id.unwrap();
4026 let live_gen = journal.get("t:live").unwrap().generation;
4027
4028 plugin_registry.register(
4031 "BrokenTripleFactory",
4032 Arc::new(|_ctx: &Arc<crate::Context>, _cfg| {
4033 Err(crate::CordisError::Configuration(
4034 "intentional batch failure".into(),
4035 ))
4036 }),
4037 );
4038 plugin_registry.register(
4039 "NeverFactory",
4040 Arc::new(|ctx: &Arc<crate::Context>, cfg| {
4041 let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
4042 #[derive(Debug)]
4043 struct Never(u64);
4044 impl crate::Service for Never {}
4045 let fut = ctx.plugin(Never(v));
4046 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
4047 }),
4048 );
4049 let desired = EntryTree(vec![
4050 Entry {
4051 id: "t:live".into(),
4052 plugin: "TripleFactory".into(),
4053 config: json!({"v": 200}),
4054 disabled: false,
4055 isolate: None,
4056 intercept: HashMap::new(),
4057 position: None,
4058 },
4059 Entry {
4060 id: "t:new".into(),
4061 plugin: "BrokenTripleFactory".into(),
4062 config: json!({}),
4063 disabled: false,
4064 isolate: None,
4065 intercept: HashMap::new(),
4066 position: None,
4067 },
4068 Entry {
4072 id: "t:never".into(),
4073 plugin: "NeverFactory".into(),
4074 config: json!({"v": 9}),
4075 disabled: false,
4076 isolate: None,
4077 intercept: HashMap::new(),
4078 position: None,
4079 },
4080 ]);
4081 let actions = Loader::apply(&ctx, &mut current, &desired, &journal).await;
4082
4083 let failed = actions
4084 .iter()
4085 .find(|a| a.id == "t:new")
4086 .expect("failing entry named in results");
4087 assert!(failed.status.is_err());
4088 assert!(
4089 failed.status.as_ref().unwrap_err().contains("intentional batch failure"),
4090 "{:?}",
4091 failed.status
4092 );
4093 assert!(
4094 !actions.iter().any(|a| a.id == "t:never" && a.status.is_ok()),
4095 "#3 must never be applied"
4096 );
4097
4098 assert_eq!(
4101 ctx.get::<Triple>().map(|t| t.0.load(Ordering::SeqCst)),
4102 Some(100),
4103 "live tree serves the original after rollback"
4104 );
4105 let rec = journal.get("t:live").unwrap();
4106 assert_eq!(rec.fiber_id, Some(live_fid));
4107 assert_eq!(rec.generation, live_gen, "no net journal churn");
4108 assert_eq!(rec.config, json!({"v": 100}), "original config restored");
4109 assert!(journal.get("t:new").is_none());
4110 assert!(journal.get("t:never").is_none());
4111 assert_eq!(current.0.len(), 1, "current stays at prior tree");
4112 }
4113
4114 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4117 async fn staged_batch_applies_in_order_on_success() {
4118 use crate::RegistryService;
4119 use std::sync::atomic::{AtomicU64, Ordering};
4120
4121 let ctx = Context::new_root();
4122 let journal = LoaderJournal::provide_new(&ctx);
4123 ctx.provide(RegistryService::new());
4124 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
4125
4126 #[derive(Debug)]
4127 struct Ordered(AtomicU64);
4128 impl Service for Ordered {}
4129
4130 plugin_registry.register(
4131 "OrderedFactory",
4132 Arc::new(|ctx: &Arc<crate::Context>, cfg| {
4133 let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
4134 let fut = ctx.plugin(Ordered(AtomicU64::new(v)));
4135 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(fut))
4136 }),
4137 );
4138
4139 plugin_registry.register(
4143 "KeepFactory",
4144 Arc::new(|_ctx: &Arc<crate::Context>, _cfg| Ok(0)),
4145 );
4146 plugin_registry.register(
4147 "ByeFactory",
4148 Arc::new(|_ctx: &Arc<crate::Context>, _cfg| Ok(0)),
4149 );
4150 let mut current = EntryTree(vec![]);
4153 Loader::apply(
4154 &ctx,
4155 &mut current,
4156 &EntryTree(vec![
4157 Entry {
4158 id: "o:keep".into(),
4159 plugin: "KeepFactory".into(),
4160 config: json!({"v": 10}),
4161 disabled: false,
4162 isolate: None,
4163 intercept: HashMap::new(),
4164 position: None,
4165 },
4166 Entry {
4167 id: "o:bye".into(),
4168 plugin: "ByeFactory".into(),
4169 config: json!({"v": 1}),
4170 disabled: false,
4171 isolate: None,
4172 intercept: HashMap::new(),
4173 position: None,
4174 },
4175 ]),
4176 &journal,
4177 )
4178 .await;
4179 let keep_fid = journal.get("o:keep").unwrap().fiber_id.unwrap();
4180 let retire_fid = journal.get("o:bye").unwrap().fiber_id.unwrap();
4181
4182 let desired = EntryTree(vec![
4183 Entry {
4185 id: "o:keep".into(),
4186 plugin: "KeepFactory".into(),
4187 config: json!({"v": 11}),
4188 disabled: false,
4189 isolate: None,
4190 intercept: HashMap::new(),
4191 position: None,
4192 },
4193 Entry {
4194 id: "o:new".into(),
4195 plugin: "OrderedFactory".into(),
4196 config: json!({"v": 2}),
4197 disabled: false,
4198 isolate: None,
4199 intercept: HashMap::new(),
4200 position: None,
4201 },
4202 ]);
4203 let actions = Loader::apply(&ctx, &mut current, &desired, &journal).await;
4204 assert!(
4205 actions.iter().all(|a| a.status.is_ok()),
4206 "every action ok: {actions:?}"
4207 );
4208 assert_eq!(actions.len(), 3, "begin + update + retire all reported");
4209
4210 assert!(
4212 ctx.get::<Ordered>().is_some(),
4213 "begin instantiated the new provider"
4214 );
4215 assert!(journal.get("o:new").is_some(), "begin settled");
4216 assert!(journal.get("o:bye").is_none(), "retire settled");
4217 assert_eq!(
4218 journal.get("o:keep").unwrap().config,
4219 json!({"v": 11}),
4220 "update settled"
4221 );
4222 assert_eq!(journal.get("o:keep").unwrap().fiber_id, Some(keep_fid));
4223 let registry = ctx.get::<crate::RegistryService>().unwrap();
4225 assert!(
4226 registry.get_fiber(retire_fid).map(|f| f.is_disposed()).unwrap_or(true),
4227 "retired fiber disposed (and pruned from tracking)"
4228 );
4229 assert_eq!(current.0.len(), 2, "tree advanced to desired");
4230 assert!(current.0.iter().all(|e| e.id != "o:bye"));
4231 }
4232
4233 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4237 async fn self_dispose_persists_disabled_true() {
4238 use crate::RegistryService;
4239
4240 let dir = tempfile::tempdir().unwrap();
4241 let path = dir.path().join("cordis-entries.toml");
4242 std::fs::write(
4243 &path,
4244 "[[entry]]\nid = \"suicide\"\nplugin = \"SelfKillFactory\"\ndisabled = false\n\n[entry.config]\n",
4245 )
4246 .unwrap();
4247
4248 let ctx = Context::new_root();
4249 let journal = LoaderJournal::provide_new(&ctx);
4250 ctx.provide(RegistryService::new());
4251 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
4252 let ops = ctx.provide(LoaderOps::new());
4253 ops.enable_self_kill_persistence(path.clone(), true);
4254
4255 #[derive(Debug)]
4256 struct Doomed;
4257 impl Service for Doomed {}
4258
4259 plugin_registry.register(
4263 "SelfKillFactory",
4264 Arc::new(|_ctx: &Arc<crate::Context>, _cfg| Ok(0)),
4265 );
4266
4267 let mut current = EntryTree(vec![]);
4268 Loader::apply(
4269 &ctx,
4270 &mut current,
4271 &EntryTree(vec![Entry {
4272 id: "suicide".into(),
4273 plugin: "SelfKillFactory".into(),
4274 config: json!({}),
4275 disabled: false,
4276 isolate: None,
4277 intercept: HashMap::new(),
4278 position: None,
4279 }]),
4280 &journal,
4281 )
4282 .await;
4283 let fid = journal.get("suicide").unwrap().fiber_id.unwrap();
4284 let registry = ctx.get::<crate::RegistryService>().unwrap();
4285 let fiber = registry.get_fiber(fid).expect("tracked");
4286
4287 fiber.dispose().await.expect("dispose runs");
4289
4290 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4293
4294 let persisted = Loader::load_from_file(&path).expect("file parses");
4296 let entry = persisted
4297 .0
4298 .iter()
4299 .find(|e| e.id == "suicide")
4300 .expect("entry still declared");
4301 assert!(
4302 entry.disabled,
4303 "self-dispose must persist disabled=true, got {entry:?}"
4304 );
4305 }
4306
4307 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4310 async fn loader_driven_dispose_does_not_persist() {
4311 use crate::RegistryService;
4312
4313 let dir = tempfile::tempdir().unwrap();
4314 let path = dir.path().join("cordis-entries.toml");
4315 std::fs::write(
4316 &path,
4317 "[[entry]]\nid = \"normal\"\nplugin = \"NormalFactory\"\ndisabled = false\n\n[entry.config]\n",
4318 )
4319 .unwrap();
4320
4321 let ctx = Context::new_root();
4322 let journal = LoaderJournal::provide_new(&ctx);
4323 ctx.provide(RegistryService::new());
4324 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
4325 let ops = ctx.provide(LoaderOps::new());
4326 ops.enable_self_kill_persistence(path.clone(), true);
4327
4328 plugin_registry.register(
4329 "NormalFactory",
4330 Arc::new(|_ctx: &Arc<crate::Context>, _cfg| Ok(0)),
4331 );
4332
4333 let mut current = EntryTree(vec![]);
4334 Loader::apply(
4335 &ctx,
4336 &mut current,
4337 &EntryTree(vec![Entry {
4338 id: "normal".into(),
4339 plugin: "NormalFactory".into(),
4340 config: json!({}),
4341 disabled: false,
4342 isolate: None,
4343 intercept: HashMap::new(),
4344 position: None,
4345 }]),
4346 &journal,
4347 )
4348 .await;
4349 let fid = journal.get("normal").unwrap().fiber_id.unwrap();
4350 let registry = ctx.get::<crate::RegistryService>().unwrap();
4351 let fiber = registry.get_fiber(fid).expect("tracked");
4352
4353 let guard = ops_enter_window_for_test(&ops);
4357 let _ = fiber.dispose().await;
4358 drop(guard);
4359
4360 let persisted = Loader::load_from_file(&path).expect("file parses");
4362 let entry = persisted.0.iter().find(|e| e.id == "normal").unwrap();
4363 assert!(!entry.disabled, "loader-driven dispose must not persist");
4364
4365 let desired = EntryTree(vec![]);
4367 let _ = Loader::apply(&ctx, &mut current, &desired, &journal).await;
4368 let persisted = Loader::load_from_file(&path).expect("file parses");
4369 let entry = persisted.0.iter().find(|e| e.id == "normal").unwrap();
4370 assert!(!entry.disabled, "reconcile retire must not persist");
4371 }
4372
4373 fn ops_enter_window_for_test(ops: &std::sync::Arc<LoaderOps>) -> LoaderWindowGuard {
4376 ops.enter_loader_window()
4377 }
4378
4379 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4391 async fn concurrent_config_updates_collapse_to_single_cascade() {
4392 use crate::RegistryService;
4393 use std::sync::atomic::Ordering;
4394
4395 let ctx = Context::new_root();
4396 let journal = LoaderJournal::provide_new(&ctx);
4397 ctx.provide(RegistryService::new());
4398 let plugin_registry = ctx.provide(crate::PluginRegistry::new());
4399
4400 #[derive(Debug)]
4402 struct CascadeProvider;
4403 impl Service for CascadeProvider {}
4404
4405 let provider_applies = Arc::new(std::sync::atomic::AtomicU64::new(0));
4406 {
4407 let counter = provider_applies.clone();
4408 plugin_registry.register(
4409 "CascadeProviderFactory",
4410 Arc::new(move |ctx, _config| {
4411 counter.fetch_add(1, Ordering::SeqCst);
4412 let future = ctx.plugin(CascadeProvider);
4413 tokio::task::block_in_place(|| {
4414 tokio::runtime::Handle::current().block_on(future)
4415 })
4416 }),
4417 );
4418 }
4419
4420 #[derive(Debug)]
4422 struct Dependent;
4423 impl Service for Dependent {}
4424
4425 let dep_fiber_holder = Arc::new(parking_lot::Mutex::<Option<std::sync::Arc<crate::Fiber>>>::new(None));
4426
4427 let dependent_applies = Arc::new(std::sync::atomic::AtomicU64::new(0));
4428 {
4429 let counter = dependent_applies.clone();
4430 let holder = dep_fiber_holder.clone();
4431 plugin_registry.register(
4432 "CascadeDependentFactory",
4433 Arc::new(move |ctx, _config| {
4434 counter.fetch_add(1, Ordering::SeqCst);
4435 let future = ctx.plugin(Dependent);
4436 let fid = tokio::task::block_in_place(|| {
4437 tokio::runtime::Handle::current().block_on(future)
4438 })?;
4439 let tracked = ctx
4440 .get::<crate::RegistryService>()
4441 .and_then(|rs| rs.get_fiber(fid));
4442 if let Some(fiber) = tracked {
4443 fiber.declare_inject::<CascadeProvider>();
4444 *holder.lock() = Some(fiber);
4445 }
4446 Ok(fid)
4447 }),
4448 );
4449 }
4450
4451 let provider_fid = Loader::instantiate(
4454 &ctx,
4455 "CascadeProviderFactory",
4456 &json!({"v": 1}),
4457 "cascade:provider",
4458 )
4459 .expect("provider begins");
4460 let dep_entry_fid = Loader::instantiate(
4461 &ctx,
4462 "CascadeDependentFactory",
4463 &json!({}),
4464 "cascade:dependent",
4465 )
4466 .expect("dependent begins");
4467
4468 let registry = ctx.get::<RegistryService>().unwrap();
4471 let dep_fiber = match dep_fiber_holder.lock().clone() {
4472 Some(fiber) => fiber,
4473 None => {
4474 let fiber = registry.get_fiber(dep_entry_fid).unwrap();
4475 fiber.declare_inject::<CascadeProvider>();
4476 fiber.clone()
4477 }
4478 };
4479
4480 if ctx.get::<crate::ReflectService>().is_none() {
4482 ctx.provide(crate::ReflectService::new());
4483 }
4484 let reflect = ctx.get::<crate::ReflectService>().unwrap();
4485 reflect.set_context(&ctx);
4486 reflect.notify_with_ctx(TypeId::of::<CascadeProvider>(), &ctx).await;
4487 assert!(
4488 matches!(dep_fiber.state(), crate::FiberState::Active { .. }),
4489 "dependent must start Active, got {:?}",
4490 dep_fiber.state()
4491 );
4492
4493 let current_shared = Arc::new(tokio::sync::Mutex::new(EntryTree(vec![Entry {
4498 id: "cascade:provider".into(),
4499 plugin: "CascadeProviderFactory".into(),
4500 config: json!({"v": 1}),
4501 disabled: false,
4502 isolate: None,
4503 intercept: HashMap::new(),
4504 position: None,
4505 }])));
4506 let mut handles = Vec::new();
4507 for round in 2..=6u32 {
4508 let ctx = ctx.clone();
4509 let journal = journal.clone();
4510 let current = current_shared.clone();
4511 handles.push(tokio::spawn(async move {
4512 let mut guard = current.lock().await;
4513 let desired = EntryTree(vec![Entry {
4514 id: "cascade:provider".into(),
4515 plugin: "CascadeProviderFactory".into(),
4516 config: json!({"v": round}),
4517 disabled: false,
4518 isolate: None,
4519 intercept: HashMap::new(),
4520 position: None,
4521 }]);
4522 Loader::apply(&ctx, &mut guard, &desired, &journal).await
4523 }));
4524 }
4525 for handle in handles {
4526 let actions = handle.await.expect("storm task joins");
4527 assert!(
4528 actions.iter().all(|a| a.status.is_ok()),
4529 "every storm batch applies: {actions:?}"
4530 );
4531 }
4532
4533 let provider_fiber = registry.get_fiber(provider_fid).unwrap();
4536 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4537 assert!(matches!(
4538 provider_fiber.state(),
4539 crate::FiberState::Active { .. }
4540 ));
4541 dep_fiber.refresh(&ctx).await;
4542 assert!(
4543 matches!(dep_fiber.state(), crate::FiberState::Active { .. }),
4544 "dependent must converge Active after the storm, got {:?}",
4545 dep_fiber.state()
4546 );
4547 assert!(
4548 ctx.get::<CascadeProvider>().is_some(),
4549 "final provider serving"
4550 );
4551
4552 let provider_runs = provider_applies.load(Ordering::SeqCst);
4560 let dependent_runs = dependent_applies.load(Ordering::SeqCst);
4561 assert!(
4562 provider_runs >= 5,
4563 "each batch re-applies the provider, got {provider_runs}"
4564 );
4565 assert!(
4566 dependent_runs <= 3,
4567 "dependent must collapse waves (deferred under the ledger), \
4568 got {dependent_runs} runs vs {provider_runs} provider runs"
4569 );
4570 }
4571
4572 struct MoveProbe(std::sync::atomic::AtomicU64);
4576 impl Service for MoveProbe {}
4577 struct MoveProbeB(std::sync::atomic::AtomicU64);
4578 impl Service for MoveProbeB {}
4579 struct MoveProbeC(std::sync::atomic::AtomicU64);
4580 impl Service for MoveProbeC {}
4581
4582 fn move_fixture(ctx: &Arc<Context>) {
4583 ctx.provide(crate::RegistryService::new());
4584 let plugins = ctx.provide(crate::PluginRegistry::new());
4585 fn reg<T: Service>(
4586 plugins: &crate::PluginRegistry,
4587 label: &str,
4588 mk: fn(u64) -> T,
4589 ) {
4590 plugins.register(
4591 label,
4592 Arc::new(move |ctx: &Arc<Context>, cfg| {
4593 let v = cfg.get("v").and_then(|x| x.as_u64()).unwrap_or(0);
4594 let fut = ctx.plugin(mk(v));
4595 tokio::task::block_in_place(|| {
4596 tokio::runtime::Handle::current().block_on(fut)
4597 })
4598 }),
4599 );
4600 }
4601 reg(&plugins, "MoveFactory", |v| MoveProbe(
4602 std::sync::atomic::AtomicU64::new(v),
4603 ));
4604 reg(&plugins, "MoveFactoryB", |v| MoveProbeB(
4605 std::sync::atomic::AtomicU64::new(v),
4606 ));
4607 reg(&plugins, "MoveFactoryC", |v| MoveProbeC(
4608 std::sync::atomic::AtomicU64::new(v),
4609 ));
4610 }
4611
4612 fn move_entry_spec(id: &str, plugin: &str, v: u64, disabled: bool) -> Entry {
4613 Entry {
4614 id: id.to_string(),
4615 plugin: plugin.to_string(),
4616 config: json!({ "v": v }),
4617 disabled,
4618 isolate: None,
4619 intercept: HashMap::new(),
4620 position: None,
4621 }
4622 }
4623
4624 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4629 async fn move_preserves_fiber_identity_and_lands_update_in_new_parent() {
4630 let ctx = Context::new_root();
4631 let journal = LoaderJournal::provide_new(&ctx);
4632 move_fixture(&ctx);
4633 let ops = ctx.provide(LoaderOps::new());
4634
4635 let mut current = EntryTree(vec![]);
4636 let desired = EntryTree(vec![
4637 move_entry_spec("grp", "MoveFactory", 1, false),
4638 move_entry_spec("svc", "MoveFactoryB", 2, false),
4639 ]);
4640 let actions = Loader::apply(&ctx, &mut current, &desired, &journal).await;
4641 assert!(actions.iter().all(|a| a.status.is_ok()), "{actions:?}");
4642 let fid = journal.get("svc").unwrap().fiber_id.unwrap();
4643 assert_eq!(ops.apply_count("svc"), 1);
4644
4645 let out = Loader::move_entry(&ctx, &mut current, &journal, "svc", Some("grp"), 0)
4646 .await
4647 .expect("move succeeds");
4648 assert!(out.noop, "pure structural move takes the noop path");
4649 assert_eq!(out.renamed, vec![("svc".to_string(), "grp:svc".to_string())]);
4650
4651 let rec = journal.get("grp:svc").expect("journal re-keyed");
4653 assert_eq!(rec.fiber_id, Some(fid));
4654 assert!(journal.get("svc").is_none(), "old key gone");
4655 let registry = ctx.get::<crate::RegistryService>().unwrap();
4656 let fiber = registry.get_fiber(fid).expect("same fiber still tracked");
4657 assert_eq!(fiber.epoch(), "grp:svc", "epoch label refreshed in place");
4658 assert!(
4659 ctx.get::<MoveProbe>().is_some(),
4660 "live instance never disposed"
4661 );
4662 assert_eq!(ops.apply_count("grp:svc"), 0);
4664 assert_eq!(ops.apply_count("svc"), 1);
4665
4666 let moved = current.0.iter().find(|e| e.id == "grp:svc").unwrap();
4668 assert_eq!(
4669 moved.position.as_ref().unwrap().parent.as_deref(),
4670 Some("grp")
4671 );
4672
4673 let updated = EntryTree(vec![
4676 move_entry_spec("grp", "MoveFactory", 1, false),
4677 move_entry_spec("grp:svc", "MoveFactoryB", 9, false),
4678 ]);
4679 let actions = Loader::apply(&ctx, &mut current, &updated, &journal).await;
4680 assert_eq!(actions.len(), 1, "{actions:?}");
4681 assert_eq!(actions[0].id, "grp:svc");
4682 assert_eq!(actions[0].action, "update-config");
4683 assert!(actions[0].status.is_ok(), "{:?}", actions[0].status);
4684 assert_eq!(journal.get("grp:svc").unwrap().fiber_id, Some(fid));
4685 assert_eq!(journal.get("grp:svc").unwrap().config, json!({ "v": 9 }));
4686 assert_eq!(ops.apply_count("grp:svc"), 0);
4688 }
4689
4690 #[test]
4692 fn descendant_move_refused() {
4693 let child = |id: &str, parent: Option<&str>| Entry {
4694 id: id.to_string(),
4695 plugin: "P".into(),
4696 position: Some(EntryPosition {
4697 parent: parent.map(str::to_string),
4698 position: 0,
4699 }),
4700 ..Default::default()
4701 };
4702 let mut tree = EntryTree(vec![
4703 child("g", None),
4704 child("g:child", Some("g")),
4705 child("g:child:leaf", Some("g:child")),
4706 ]);
4707 let snapshot = tree.clone();
4708
4709 let err = tree.move_entry("g", Some("g:child"), 0).unwrap_err();
4710 assert!(err.contains("descendant"), "{err}");
4711 let err = tree.move_entry("g", Some("g:child:leaf"), 0).unwrap_err();
4712 assert!(err.contains("descendant"), "{err}");
4713 let err = tree.move_entry("g", Some("g"), 0).unwrap_err();
4714 assert!(err.contains("itself"), "{err}");
4715 assert_eq!(tree, snapshot, "refusals leave the tree untouched");
4716 }
4717
4718 #[test]
4721 fn subtree_rename_cascades_descendants() {
4722 let e = |id: &str, parent: Option<&str>| Entry {
4723 id: id.to_string(),
4724 plugin: "P".into(),
4725 position: parent.map(|p| EntryPosition {
4726 parent: Some(p.to_string()),
4727 position: 0,
4728 }),
4729 ..Default::default()
4730 };
4731 let mut tree = EntryTree(vec![
4732 e("other", None),
4733 e("g:a", Some("g")),
4734 e("g:a:b", Some("g:a")),
4735 e("unrelated", None),
4736 e("g:a:b:deep", Some("g:a:b")),
4737 ]);
4738 let renames = tree.move_entry("g:a", None, 3).unwrap();
4740 assert_eq!(
4741 renames,
4742 vec![
4743 ("g:a".to_string(), "a".to_string()),
4744 ("g:a:b".to_string(), "a:b".to_string()),
4745 ("g:a:b:deep".to_string(), "a:b:deep".to_string()),
4746 ]
4747 );
4748 let ids: Vec<&str> = tree.0.iter().map(|e| e.id.as_str()).collect();
4749 assert_eq!(ids, vec!["other", "a", "a:b", "unrelated", "a:b:deep"]);
4750 let pos = |id: &str| {
4751 tree.0
4752 .iter()
4753 .find(|e| e.id == id)
4754 .unwrap()
4755 .position
4756 .as_ref()
4757 .unwrap()
4758 .parent
4759 .clone()
4760 };
4761 assert_eq!(pos("a:b"), Some("a".to_string()), "pointer remapped");
4762 assert_eq!(pos("a:b:deep"), Some("a:b".to_string()));
4763 assert_eq!(pos("a"), None, "moved root landed at tree root");
4764 }
4765
4766 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4769 async fn disabled_group_move_suppresses_start_then_restores() {
4770 let ctx = Context::new_root();
4771 let journal = LoaderJournal::provide_new(&ctx);
4772 move_fixture(&ctx);
4773 let ops = ctx.provide(LoaderOps::new());
4774
4775 let mut current = EntryTree(vec![]);
4776 let mut g = move_entry_spec("g", "MoveFactoryB", 1, false);
4777 g.position = Some(EntryPosition::default());
4778 let mut kid = move_entry_spec("g:kid", "MoveFactoryC", 2, false);
4779 kid.position = Some(EntryPosition {
4780 parent: Some("g".into()),
4781 position: 0,
4782 });
4783 let desired = EntryTree(vec![move_entry_spec("other", "MoveFactory", 3, false), g, kid]);
4784 let actions = Loader::apply(&ctx, &mut current, &desired, &journal).await;
4785 assert!(actions.iter().all(|a| a.status.is_ok()), "{actions:?}");
4786
4787 let disabled = EntryTree(
4789 desired
4790 .0
4791 .iter()
4792 .map(|e| {
4793 let mut c = e.clone();
4794 if c.id == "g" || c.id == "g:kid" {
4795 c.disabled = true;
4796 }
4797 c
4798 })
4799 .collect(),
4800 );
4801 let actions = Loader::apply(&ctx, &mut current, &disabled, &journal).await;
4802 assert!(actions.iter().all(|a| a.action == "retire"), "{actions:?}");
4803 assert!(journal.get("g").is_none() && journal.get("g:kid").is_none());
4804
4805 let out = Loader::move_entry(&ctx, &mut current, &journal, "g", Some("other"), 0)
4808 .await
4809 .expect("move succeeds");
4810 assert!(out.noop);
4811 assert_eq!(
4812 out.renamed,
4813 vec![
4814 ("g".to_string(), "other:g".to_string()),
4815 ("g:kid".to_string(), "other:g:kid".to_string()),
4816 ]
4817 );
4818 assert!(journal.get("other:g").is_none());
4819 assert!(journal.get("other:g:kid").is_none());
4820 assert_eq!(
4821 ops.apply_count("other:g") + ops.apply_count("other:g:kid"),
4822 0,
4823 "moving a disabled group must not start fibers"
4824 );
4825
4826 let restored = EntryTree(vec![
4828 move_entry_spec("other", "MoveFactory", 3, false),
4829 move_entry_spec("other:g", "MoveFactoryB", 1, false),
4830 {
4831 let mut k = move_entry_spec("other:g:kid", "MoveFactoryC", 2, false);
4832 k.position = Some(EntryPosition {
4833 parent: Some("other:g".into()),
4834 position: 0,
4835 });
4836 k
4837 },
4838 ]);
4839 let actions = Loader::apply(&ctx, &mut current, &restored, &journal).await;
4840 assert_eq!(actions.len(), 2, "{actions:?}");
4841 assert!(actions
4842 .iter()
4843 .all(|a| a.action == "begin" && a.status.is_ok()));
4844 assert!(journal.get("other:g").unwrap().fiber_id.is_some());
4845 assert!(journal.get("other:g:kid").unwrap().fiber_id.is_some());
4846 assert!(ctx.get::<MoveProbe>().is_some());
4847 }
4848
4849 #[test]
4852 fn invalid_move_errors_without_mutating_tree() {
4853 let e = |id: &str, parent: Option<&str>| Entry {
4854 id: id.to_string(),
4855 plugin: format!("Plugin-{id}"),
4856 position: parent.map(|p| EntryPosition {
4857 parent: Some(p.to_string()),
4858 position: 0,
4859 }),
4860 ..Default::default()
4861 };
4862 let mut tree = EntryTree(vec![
4863 e("a", None),
4864 e("b", None),
4865 e("b:a", Some("b")), ]);
4867 let snapshot = tree.clone();
4868
4869 let err = tree.move_entry("a", Some("b"), 0).unwrap_err();
4871 assert!(err.contains("already used by plugin 'Plugin-b:a'"), "{err}");
4872 assert!(tree.move_entry("nope", None, 0).is_err());
4874 assert!(tree.move_entry("a", Some("nope"), 0).is_err());
4875
4876 assert_eq!(tree, snapshot, "failed moves never mutate the tree");
4877 }
4878}