Skip to main content

cordis/
lib.rs

1#![allow(missing_docs)]
2#![allow(dead_code)]
3
4use parking_lot::RwLock;
5use std::any::TypeId;
6use std::collections::{HashMap, HashSet, VecDeque};
7use std::sync::{Arc, Weak};
8use tokio::sync::watch;
9
10// Inventory / linkme static registration — real compile-time collection
11#[cfg(feature = "inventory")]
12pub struct CordisInventory {
13    pub name: &'static str,
14}
15
16#[cfg(feature = "inventory")]
17inventory::collect!(CordisInventory);
18
19#[cfg(feature = "inventory")]
20inventory::submit! {
21    CordisInventory { name: "RegistryService" }
22}
23
24#[cfg(feature = "inventory")]
25inventory::submit! {
26    CordisInventory { name: "EventsService" }
27}
28
29#[cfg(feature = "inventory")]
30inventory::submit! {
31    CordisInventory { name: "ReflectService" }
32}
33
34#[cfg(feature = "inventory")]
35inventory::submit! {
36    CordisInventory { name: "Loader" }
37}
38
39// Kernel factory submits — same feature gates as the manual registrations.
40#[cfg(feature = "inventory")]
41inventory::submit! {
42    CordisPluginFactory { name: "EventsService", make: factory_events_service }
43}
44#[cfg(all(feature = "inventory", feature = "rhai"))]
45inventory::submit! {
46    CordisPluginFactory { name: "RhaiPolicy", make: factory_rhai_policy }
47}
48
49/// Function-pointer form of [`PluginFactory`] carried through inventory.
50///
51/// Factories are plain `fn` items so they can cross crate boundaries as
52/// static data; the registry wraps them in the usual `Arc<dyn Fn>`.
53#[cfg(feature = "inventory")]
54pub type PluginFactoryFn = fn(&Arc<Context>, &serde_json::Value) -> Result<FiberId, CordisError>;
55
56/// Compile-time collected plugin factory (inventory feature only).
57#[cfg(feature = "inventory")]
58pub struct CordisPluginFactory {
59    /// Loader plugin key (e.g. `"Http"`, `"SchedulerService"`).
60    pub name: &'static str,
61    /// The factory function itself.
62    pub make: PluginFactoryFn,
63}
64
65#[cfg(feature = "inventory")]
66inventory::collect!(CordisPluginFactory);
67
68/// Register every inventory-collected factory onto `reg`.
69///
70/// This is the primary registration path for binaries built with the
71/// default features; the hand-written per-crate `register_plugins` chains
72/// remain as the fallback when `inventory` is off.
73#[cfg(feature = "inventory")]
74pub fn register_inventory_factories(reg: &PluginRegistry) {
75    for entry in inventory::iter::<CordisPluginFactory> {
76        reg.register(entry.name, Arc::new(entry.make));
77    }
78}
79
80#[cfg(feature = "inventory")]
81pub fn inventory_len() -> usize {
82    inventory::iter::<CordisInventory>.into_iter().count()
83}
84
85#[cfg(not(feature = "inventory"))]
86pub fn inventory_len() -> usize {
87    0
88}
89
90pub mod context;
91pub mod effect;
92pub mod error;
93pub mod events;
94pub mod fiber;
95pub mod logger;
96pub mod service;
97pub mod timer;
98
99pub use context::{Accessor, Context, EffectHandle};
100pub use effect::Disposable;
101pub use events::{summarize_listener_errors, AggregateError, Dispatch, EventsService};
102pub use error::{ValidationError, ValidationIssue};
103pub use fiber::{Fiber, FiberState, UndoMeta};
104pub use service::{CordisError, Service, ServiceInitFuture};
105
106pub mod events_catalog;
107pub use events_catalog::{contract_for, validate_dispatch, validate_listener, EventContract};
108pub mod events_payload;
109pub use events_payload::{
110    AgentAdmitEvent, AgentAdmitPayload, AgentCompletedEvent, AgentCompletedPayload,
111    AgentFailedEvent, AgentFailedPayload, AgentRunEvent, AgentRunRequest, AgentRunResult,
112    AgentStartedEvent, AgentStartedPayload, AgentUsageEvent, AgentUsagePayload, LlmCompleteEvent,
113    LlmCompleteRequest, LlmCompleteResult, LlmGenerateEvent, LlmGeneratePayload,
114    LlmGenerateToolsEvent, LlmGenerateToolsPayload, LlmGetClientEvent, LlmGetClientPayload,
115    LlmMessage, PipelineFanoutCompletedEvent, PipelineFanoutCompletedPayload,
116    PipelineStepFinishedEvent, PipelineStepFinishedPayload, PipelineStepStartedEvent,
117    PipelineStepStartedPayload, ScheduleDispatchedEvent, ScheduleDispatchedPayload,
118    SchedulerAdmitEvent, SchedulerAdmitPayload, SchedulerBeforeRunEvent, SchedulerBeforeRunPayload,
119    SchedulerTickEvent, SchedulerTickPayload, ServiceChangedEvent, ServiceChangedPayload,
120    ToolsExecuteEvent, ToolsExecutePayload, ToolsListEvent, ToolsListRequest, ToolsListResult,
121    ToolsResolveEvent, ToolsResolveRequest, TriggerFiredEvent, TriggerFiredPayload, TypedEvent,
122};
123pub mod loader;
124pub use loader::{
125    AppliedAction, CurrentEntries, Entry, EntryConfigFiller, EntryConfigFillerHandle, EntryTree,
126    EntryUpdate, Loader, LoaderOps,
127};
128
129pub mod cycles;
130pub use cycles::{find_dependency_cycle, DependencyGraph};
131
132pub mod reload;
133pub mod stamp;
134pub use reload::reload_entries_from_disk;
135pub use stamp::{FileStamp, ReloadOutcome};
136pub use watcher::SettleBarrier;
137
138pub mod metatheory;
139
140pub mod hmr;
141pub mod module_graph;
142pub mod registry;
143pub mod watcher;
144pub use registry::{Plugin, RegistryService};
145pub use module_graph::{ChangeOutcome, ModuleEntry, ModuleGraph, ModuleReload, NoopReload};
146
147pub use logger::{
148    derived_name, hyphenate, Exporter, ExporterConfig, LogArg, LogKind, LogLevel,
149    LoggerIntercept, LoggerService, Message,
150};
151
152pub mod compose;
153#[cfg(feature = "rhai")]
154pub mod rhai_service;
155pub mod worker;
156
157#[cfg(feature = "rhai")]
158pub use compose::{
159    compose_all, compose_entries, interpolate_config, resolve_includes, GROUP_PLUGIN,
160    INCLUDE_PLUGIN,
161};
162#[cfg(not(feature = "rhai"))]
163pub use compose::{compose_all, resolve_includes, GROUP_PLUGIN, INCLUDE_PLUGIN};
164#[cfg(feature = "rhai")]
165pub use rhai_service::{RhaiListenerConfig, RhaiPlugin, RhaiService, RhaiServiceConfig};
166
167pub type Symbol = String;
168pub type EventId = String;
169pub type FiberId = u64;
170
171pub fn compute_epoch(inject: &HashMap<TypeId, Symbol>) -> String {
172    if inject.is_empty() {
173        return ":".to_string();
174    }
175    let mut frags: Vec<String> = inject.values().cloned().collect();
176    frags.sort();
177    format!(":{}", frags.join(":"))
178}
179
180// RegistryService and Plugin live in registry.rs to keep single-source discipline
181// and isolate-aware checks in one place. Re-exported here for ergonomics.
182
183// ---------------------------------------------------------------------------
184// PluginRegistry — name → factory map consumed by `Loader::instantiate`
185// ---------------------------------------------------------------------------
186
187/// Factory closure that turns one declarative entry into a live fiber.
188///
189/// The body must call [`Context::plugin`] (directly or via a helper) so that
190/// single-source discipline applies: a factory whose service is already
191/// provided fails with `CordisError::Configuration("duplicate provider …")`
192/// instead of silently shadowing it.
193pub type PluginFactory =
194    Arc<dyn Fn(&Arc<Context>, &serde_json::Value) -> Result<FiberId, CordisError> + Send + Sync>;
195
196/// Name-keyed directory of [`PluginFactory`] closures.
197///
198/// Registered at bootstrap (`root_ctx.provide(PluginRegistry::new())` +
199/// `register(name, …)`); consulted by `Loader::instantiate` when applying
200/// entries from `config/cordis-entries.toml`. Entries naming a plugin with no
201/// registered factory fail their own instantiation but never abort startup.
202pub struct PluginRegistry {
203    factories: RwLock<HashMap<String, PluginFactory>>,
204}
205
206impl PluginRegistry {
207    pub fn new() -> Self {
208        Self {
209            factories: RwLock::new(HashMap::new()),
210        }
211    }
212
213    pub fn register(&self, name: &str, f: PluginFactory) {
214        self.factories.write().insert(name.to_string(), f);
215    }
216
217    pub fn get(&self, name: &str) -> Option<PluginFactory> {
218        self.factories.read().get(name).cloned()
219    }
220
221    pub fn names(&self) -> Vec<String> {
222        self.factories.read().keys().cloned().collect()
223    }
224}
225
226impl Default for PluginRegistry {
227    fn default() -> Self {
228        Self::new()
229    }
230}
231
232impl Service for PluginRegistry {}
233
234fn block_on_plugin<S: Service + 'static>(
235    ctx: &Arc<Context>,
236    svc: S,
237) -> Result<FiberId, CordisError> {
238    tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(ctx.plugin(svc)))
239}
240
241/// Kernel `EventsService` loader factory.
242pub fn factory_events_service(
243    ctx: &Arc<Context>,
244    _config: &serde_json::Value,
245) -> Result<FiberId, CordisError> {
246    block_on_plugin(ctx, EventsService::new())
247}
248
249/// Kernel `RhaiPolicy` loader factory (feature `rhai`).
250#[cfg(feature = "rhai")]
251pub fn factory_rhai_policy(
252    ctx: &Arc<Context>,
253    config: &serde_json::Value,
254) -> Result<FiberId, CordisError> {
255    let cfg: RhaiServiceConfig = serde_json::from_value(config.clone())
256        .map_err(|e| CordisError::Configuration(format!("invalid RhaiPolicy config: {e}")))?;
257    tokio::task::block_in_place(|| {
258        tokio::runtime::Handle::current().block_on(ctx.plugin_with(RhaiPlugin, cfg))
259    })
260}
261
262/// Register kernel string factories consumed by the declarative loader.
263pub fn register_plugins(reg: &PluginRegistry) {
264    reg.register("EventsService", Arc::new(factory_events_service));
265    #[cfg(feature = "rhai")]
266    reg.register("RhaiPolicy", Arc::new(factory_rhai_policy));
267}
268
269// ---------------------------------------------------------------------------
270// ReflectService — Phase 3 unified hot-reload (watch + BFS via Fiber::refresh)
271// ---------------------------------------------------------------------------
272
273/// Unified hot-reload coordinator — replaces 60s `ArcSwap` polling.
274///
275/// Tracks `notifiers: RwLock<HashMap<TypeId, watch::Sender<()>>>` for DB/file
276/// change fan-out and `dependents: RwLock<HashMap<TypeId, Vec<FiberId>>>` for
277/// BFS dependency walks. `notify(TypeId)` BFS-walks `dependents` and calls
278/// `Fiber::refresh` on each dependent fiber, using the same `Fiber` impl that
279/// recomputes `epoch` from `inject` versions (see `Fiber::refresh`). Watch
280/// channels are created lazily on `provide` via `ensure_notifier` — prove by
281/// calling it on registry creation (e.g. `RuntimeToolRegistry` / `ProviderRegistry`
282/// insertion). See `docs/cordis-mapping.md` §7, §11.
283///
284/// `fibers` / `fiber_provides` / `ctx` are extra bookkeeping for BFS + async
285/// `refresh`; `notifiers` + `dependents` are the required fields per spec.
286#[allow(dead_code)]
287pub struct ReflectService {
288    notifiers: RwLock<HashMap<TypeId, watch::Sender<()>>>,
289    dependents: RwLock<HashMap<TypeId, Vec<FiberId>>>,
290    fibers: RwLock<HashMap<FiberId, Arc<Fiber>>>,
291    fiber_provides: RwLock<HashMap<FiberId, TypeId>>,
292    ctx: RwLock<Option<Weak<Context>>>,
293}
294
295impl ReflectService {
296    pub fn new() -> Self {
297        Self {
298            notifiers: RwLock::new(HashMap::new()),
299            dependents: RwLock::new(HashMap::new()),
300            fibers: RwLock::new(HashMap::new()),
301            fiber_provides: RwLock::new(HashMap::new()),
302            ctx: RwLock::new(None),
303        }
304    }
305
306    /// Ensure a `watch` channel exists for `tid`; create lazily on `provide`.
307    /// Returns a `Receiver` that callers can `changed().await` on for DB/file updates.
308    /// This is the “provide watch channel creation on provide” hook — call after
309    /// `ctx.provide::<T>(svc)` to prove compile-time insertion.
310    pub fn ensure_notifier(&self, tid: TypeId) -> watch::Receiver<()> {
311        let mut notifiers = self.notifiers.write();
312        if let Some(sender) = notifiers.get(&tid) {
313            return sender.subscribe();
314        }
315        let (tx, rx) = watch::channel(());
316        notifiers.insert(tid, tx);
317        rx
318    }
319
320    /// Convenience: ensure notifier for a `Service` type.
321    pub fn ensure_notifier_for<T: Service>(&self) -> watch::Receiver<()> {
322        self.ensure_notifier(TypeId::of::<T>())
323    }
324
325    /// Register that `fid` depends on `tid` (i.e. `fid.injects` contains `tid`).
326    /// Populates `dependents` for BFS walks.
327    pub fn register_dependent(&self, tid: TypeId, fid: FiberId) {
328        let mut deps = self.dependents.write();
329        let entry = deps.entry(tid).or_default();
330        if !entry.contains(&fid) {
331            entry.push(fid);
332        }
333    }
334
335    /// Register a fiber and what `TypeId` it provides (for transitive BFS).
336    /// Call from `RegistryService::plugin` after allocating `fid`.
337    pub fn register_fiber(&self, fid: FiberId, fiber: Arc<Fiber>, provides: TypeId) {
338        self.fibers.write().insert(fid, fiber);
339        self.fiber_provides.write().insert(fid, provides);
340    }
341
342    /// Remember the root `Context` weakly so `notify` can `upgrade()` and call
343    /// `Fiber::refresh` without caller passing `ctx`.
344    pub fn set_context(&self, ctx: &Arc<Context>) {
345        *self.ctx.write() = Some(Arc::downgrade(ctx));
346    }
347
348    /// BFS walks `dependents` starting at `tid`, notifies `watch` senders,
349    /// and spawns `Fiber::refresh` for each dependent fiber (uses existing
350    /// `Fiber::refresh` impl). This replaces the 60s `ArcSwap` poll;
351    /// registry reload is now triggered by `notify` via `watch` channel on DB
352    /// `NOTIFY`/`LISTEN` or file change, not a timer.
353    pub fn notify(&self, tid: TypeId) {
354        self.prune_disposed();
355        // Snapshot context weakly; if no context, still notify watch channels
356        let ctx_opt = self.ctx.read().as_ref().and_then(|w| w.upgrade());
357
358        // Emit service.changed event via EventsService (fire-and-forget)
359        if let Some(ctx) = &ctx_opt {
360            if let Some(events) = ctx.get::<EventsService>() {
361                let payload = crate::ServiceChangedPayload {
362                    type_id: format!("{tid:?}"),
363                    event: crate::events_catalog::ev::SERVICE_CHANGED.to_string(),
364                };
365                tokio::spawn(async move {
366                    let _ = events
367                        .dispatch_typed::<crate::ServiceChangedEvent>(&payload)
368                        .await;
369                });
370            }
371        }
372
373        let mut queue = VecDeque::new();
374        let mut visited_type = HashSet::new();
375        let mut visited_fiber = HashSet::new();
376        queue.push_back(tid);
377        visited_type.insert(tid);
378        while let Some(cur) = queue.pop_front() {
379            // Fan-out via watch channel
380            if let Some(sender) = self.notifiers.read().get(&cur).cloned() {
381                let _ = sender.send(());
382            }
383            // BFS over dependent fibers
384            let fids = self
385                .dependents
386                .read()
387                .get(&cur)
388                .cloned()
389                .unwrap_or_default();
390            for fid in fids {
391                if !visited_fiber.insert(fid) {
392                    continue;
393                }
394                let fiber_opt = self.fibers.read().get(&fid).cloned();
395                if let Some(fiber) = fiber_opt {
396                    if let Some(ctx) = ctx_opt.clone() {
397                        let fiber_clone = fiber.clone();
398                        tokio::spawn(async move {
399                            fiber_clone.refresh(&ctx).await;
400                        });
401                    }
402                    // Transitive: if this fiber provides a TypeId, enqueue its dependents
403                    if let Some(provided) = self.fiber_provides.read().get(&fid).copied() {
404                        if visited_type.insert(provided) {
405                            queue.push_back(provided);
406                        }
407                    }
408                }
409            }
410        }
411    }
412
413    /// Async variant that `await`s each `Fiber::refresh` directly (for tests / direct callers that have `ctx`).
414    #[allow(clippy::await_holding_lock)]
415    pub async fn notify_with_ctx(&self, tid: TypeId, ctx: &Arc<Context>) {
416        self.prune_disposed();
417        let mut queue = VecDeque::new();
418        let mut visited_type = HashSet::new();
419        let mut visited_fiber = HashSet::new();
420        queue.push_back(tid);
421        visited_type.insert(tid);
422        while let Some(cur) = queue.pop_front() {
423            if let Some(sender) = self.notifiers.read().get(&cur).cloned() {
424                let _ = sender.send(());
425            }
426            let fids = self
427                .dependents
428                .read()
429                .get(&cur)
430                .cloned()
431                .unwrap_or_default();
432            for fid in fids {
433                if !visited_fiber.insert(fid) {
434                    continue;
435                }
436                let fiber = { self.fibers.read().get(&fid).cloned() };
437                if let Some(fiber) = fiber {
438                    fiber.refresh(ctx).await;
439                    if let Some(provided) = self.fiber_provides.read().get(&fid).copied() {
440                        if visited_type.insert(provided) {
441                            queue.push_back(provided);
442                        }
443                    }
444                }
445            }
446        }
447    }
448
449    /// Drop `fibers` / `fiber_provides` entries whose disposal already ran.
450    ///
451    /// The BFS walk never drops entries on its own, so disposed fibers used
452    /// to accumulate here forever. Pruning runs opportunistically at the top
453    /// of each notify: a pruned fiber can no longer be refreshed, which is
454    /// exactly right — disposal already ran its undos. `Failed{error}`
455    /// fibers are NOT disposed (see [`Fiber::is_disposed`]) and stay
456    /// inspectable by design.
457    pub fn prune_disposed(&self) -> usize {
458        let dead: Vec<FiberId> = self
459            .fibers
460            .read()
461            .iter()
462            .filter(|(_, fiber)| fiber.is_disposed())
463            .map(|(fid, _)| *fid)
464            .collect();
465        let mut removed = 0;
466        {
467            let mut fibers = self.fibers.write();
468            for fid in &dead {
469                if fibers.remove(fid).is_some() {
470                    removed += 1;
471                }
472            }
473        }
474        self.fiber_provides
475            .write()
476            .retain(|fid, _| !dead.contains(fid));
477        removed
478    }
479
480    /// Get a `watch::Receiver` if already created (no creation).
481    pub fn subscribe(&self, tid: TypeId) -> Option<watch::Receiver<()>> {
482        self.notifiers.read().get(&tid).map(|s| s.subscribe())
483    }
484}
485
486impl Default for ReflectService {
487    fn default() -> Self {
488        Self::new()
489    }
490}
491
492impl Service for ReflectService {}
493
494// Inventory/linkme static registration placeholder (preferred for production).
495// Real static registration would use `inventory::submit!` or `linkme::distributed_slice`
496// to collect `fn(&Arc<Context>) -> Result<FiberId, CordisError>` at compile time.
497// This spike stubs it — Phase 3 Loader will drive declarative reconciliation.
498// Behind `#[cfg(feature = "hmr")]`, `libloading` would `dlopen` a `.so` and call `Plugin::apply`
499// via an `extern "C"` entry point; if ABI fragility blocks, fallback is file-watch + full fiber reload
500// (see docs/cordis-mapping.md §11 — 90% value without dynamic code).
501
502// ---------------------------------------------------------------------------
503// LoaderJournal — live bookkeeping for `Loader::execute_action` / `instantiate`
504// ---------------------------------------------------------------------------
505
506/// One record in the [`LoaderJournal`]: the plugin label owning an entry, the
507/// last applied config, the live fiber id when known, and a monotonically
508/// increasing generation counter.  `generation` lets reconciliation callers
509/// detect whether an entry's config actually changed (see
510/// [`Loader::execute_action`](crate::loader::Loader::execute_action)).
511#[derive(Debug, Clone, PartialEq, Eq, Default)]
512pub struct JournalRecord {
513    pub plugin: String,
514    pub config: serde_json::Value,
515    pub fiber_id: Option<FiberId>,
516    pub generation: u64,
517}
518
519/// Optional journal that makes the loader lifecycle real for `UpdateConfig`
520/// and `Retire` arms.
521///
522/// Provide it as a `Service` (`ctx.provide(LoaderJournal::new())`) so
523/// [`Context::get::<LoaderJournal>`] returns the shared handle; when absent,
524/// [`Loader::execute_action`](crate::loader::Loader::execute_action) and
525/// [`Loader::instantiate`](crate::loader::Loader::instantiate) degrade to
526/// log-only.  Every mutation bumps `generation`; the journal is the single
527/// source of truth for "is this entry live, with which fiber, at what
528/// config/version".
529#[derive(Clone, Default)]
530pub struct LoaderJournal {
531    records: Arc<RwLock<HashMap<String, JournalRecord>>>,
532}
533
534impl LoaderJournal {
535    pub fn new() -> Self {
536        Self::default()
537    }
538
539    /// Construct the journal and provide it on `ctx` in one step.
540    pub fn provide_new(ctx: &std::sync::Arc<Context>) -> std::sync::Arc<Self> {
541        let journal = std::sync::Arc::new(Self::default());
542        ctx.provide_arc(journal.clone());
543        journal
544    }
545
546    /// Insert or replace a record, bumping generation by 1 from the prior value
547    /// (or from 0 for a fresh id).
548    pub fn upsert(
549        &self,
550        id: &str,
551        plugin: &str,
552        config: serde_json::Value,
553        fiber_id: Option<FiberId>,
554    ) {
555        let mut records = self.records.write();
556        let generation = records.get(id).map(|r| r.generation).unwrap_or(0) + 1;
557        records.insert(
558            id.to_string(),
559            JournalRecord {
560                plugin: plugin.to_string(),
561                config,
562                fiber_id,
563                generation,
564            },
565        );
566    }
567
568    /// Replace the stored config for `id`, bumping generation, and optionally
569    /// refresh the tracked fiber id.  Returns the prior record if present.
570    pub fn update_config(
571        &self,
572        id: &str,
573        new_config: serde_json::Value,
574        fiber_id: Option<FiberId>,
575    ) -> Option<JournalRecord> {
576        let mut records = self.records.write();
577        let record = records.get_mut(id)?;
578        record.config = new_config;
579        if let Some(fid) = fiber_id {
580            record.fiber_id = Some(fid);
581        }
582        record.generation += 1;
583        Some(record.clone())
584    }
585
586    /// Remove `id` from the journal (retirement).  Returns the removed record.
587    pub fn retire(&self, id: &str) -> Option<JournalRecord> {
588        self.records.write().remove(id)
589    }
590    /// Re-key the record `old` → `new` (subtree move), PRESERVING the plugin
591    /// label, config, generation, and tracked fiber id. This is how a
592    /// structural entry move keeps its live fiber: the record moves, the
593    /// fiber does not. Returns the record under its new key, or `None` when
594    /// `old` was not journaled.
595    pub fn rename(&self, old: &str, new: &str) -> Option<JournalRecord> {
596        let mut records = self.records.write();
597        let record = records.remove(old)?;
598        records.insert(new.to_string(), record.clone());
599        Some(record)
600    }
601
602    pub fn get(&self, id: &str) -> Option<JournalRecord> {
603        self.records.read().get(id).cloned()
604    }
605
606    pub fn len(&self) -> usize {
607        self.records.read().len()
608    }
609
610    pub fn is_empty(&self) -> bool {
611        self.records.read().is_empty()
612    }
613}
614
615impl Service for LoaderJournal {}
616
617// ---------------------------------------------------------------------------
618// Tests — the two theorems that must hold before Phase 2
619// ---------------------------------------------------------------------------
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624    use parking_lot::Mutex;
625
626    #[test]
627    fn inventory_len_is_kernel_only() {
628        #[cfg(feature = "inventory")]
629        assert_eq!(inventory_len(), 4);
630        #[cfg(not(feature = "inventory"))]
631        assert_eq!(inventory_len(), 0);
632    }
633
634    #[derive(Debug)]
635    struct FooService(pub i32);
636    impl Service for FooService {}
637
638    #[derive(Debug)]
639    struct BarService(pub i32);
640    impl Service for BarService {}
641
642    #[derive(Debug)]
643    struct ConsumerService;
644    impl Service for ConsumerService {}
645
646    #[tokio::test]
647    async fn temporal_composability() {
648        // Register plugin → mutate context → dispose fiber → assert context recovered
649        let ctx = Context::new_root();
650        let pre_len = ctx.snapshot_len();
651        assert!(ctx.get::<BarService>().is_none());
652
653        // mutate via provide (witnessed effect)
654        let bar = ctx.provide(BarService(42));
655        assert_eq!(bar.0, 42);
656        assert!(ctx.get::<BarService>().is_some());
657        assert_eq!(ctx.snapshot_len(), pre_len + 1);
658
659        // dispose fiber should LIFO revert
660        let _ = ctx.fiber().dispose().await;
661        assert!(ctx.get::<BarService>().is_none());
662        assert_eq!(ctx.snapshot_len(), pre_len);
663    }
664
665    #[tokio::test]
666    async fn spatial_composability() {
667        // Provide service A → fiber depending on A activates → re-provide A → fiber automatically reloads
668        let ctx = Context::new_root();
669        let consumer_fiber = Arc::new(Fiber::new());
670        consumer_fiber.declare_inject::<FooService>();
671
672        // Initially Inactive (dep missing)
673        assert_eq!(consumer_fiber.state(), FiberState::Inactive { error: None });
674        assert_eq!(consumer_fiber.epoch(), "");
675
676        // Provide FooService v1 -> fiber should become Active after refresh
677        ctx.provide(FooService(1));
678        consumer_fiber.refresh(&ctx).await;
679        assert!(matches!(consumer_fiber.state(), FiberState::Active { .. }));
680        let epoch_v1 = consumer_fiber.epoch();
681        assert!(epoch_v1.contains("FooService"));
682        assert!(epoch_v1.contains(":1") || epoch_v1.contains("1"));
683
684        // Re-provide FooService v2 -> epoch should change and reload triggered
685        ctx.provide(FooService(2));
686        let prev_epoch = epoch_v1.clone();
687        consumer_fiber.refresh(&ctx).await;
688        let epoch_v2 = consumer_fiber.epoch();
689        assert_ne!(prev_epoch, epoch_v2);
690        assert!(matches!(consumer_fiber.state(), FiberState::Active { .. }));
691        // Ensure new provider visible
692        assert_eq!(ctx.get::<FooService>().unwrap().0, 2);
693    }
694
695    #[tokio::test]
696    async fn isolate_and_intercept() {
697        let root = Context::new_root();
698        root.provide(FooService(10));
699        assert_eq!(root.get::<FooService>().unwrap().0, 10);
700
701        // isolate tenant
702        let tenant_ctx = root.isolate::<FooService>("tenant:acme");
703        // tenant initially has no Foo (isolated) — but parent lookup would still find root's Foo
704        // Our get walks parent, so it will find root's Foo. Isolate semantics: should not leak?
705        // For spike, we test that tenant can provide its own Foo without affecting root
706        tenant_ctx.provide(FooService(99));
707        assert_eq!(tenant_ctx.get::<FooService>().unwrap().0, 99);
708        assert_eq!(root.get::<FooService>().unwrap().0, 10);
709
710        // intercept per-request override
711        let req_ctx = root.intercept(FooService(77));
712        assert_eq!(req_ctx.get::<FooService>().unwrap().0, 77);
713        // root unchanged
714        assert_eq!(root.get::<FooService>().unwrap().0, 10);
715    }
716
717    #[tokio::test]
718    async fn events_dispatch_modes() {
719        let svc = EventsService::new();
720        svc.on("test".into(), |v| async move {
721            let n = v.as_i64().unwrap_or(0);
722            Ok(serde_json::Value::Number((n + 1).into()))
723        });
724        let out = svc
725            .dispatch(
726                "test".into(),
727                serde_json::Value::Number(1.into()),
728                Dispatch::Serial,
729            )
730            .await
731            .unwrap();
732        assert_eq!(out, serde_json::Value::Number(2.into()));
733    }
734
735    #[tokio::test]
736    async fn epoch_monoid() {
737        let mut map = HashMap::new();
738        map.insert(TypeId::of::<FooService>(), "uid1".to_string());
739        map.insert(TypeId::of::<BarService>(), "uid2".to_string());
740        let e = compute_epoch(&map);
741        assert!(e.starts_with(':'));
742        assert!(e.contains("uid1"));
743        assert!(e.contains("uid2"));
744        // Empty
745        let empty: HashMap<TypeId, Symbol> = HashMap::new();
746        assert_eq!(compute_epoch(&empty), ":");
747    }
748
749    #[tokio::test]
750    async fn fiber_inertia_serializes_transitions() {
751        let fiber = Arc::new(Fiber::new());
752        fiber.declare_inject::<FooService>();
753        let ctx = Context::new_root();
754        // concurrent refreshes should serialize via inertia mutex
755        let f1 = fiber.clone();
756        let c1 = ctx.clone();
757        let f2 = fiber.clone();
758        let c2 = ctx.clone();
759        let (r1, r2) = tokio::join!(f1.refresh(&c1), f2.refresh(&c2));
760        // both should complete without deadlock
761        let _ = (r1, r2);
762        assert!(matches!(
763            fiber.state(),
764            FiberState::Inactive { .. } | FiberState::Active { .. }
765        ));
766    }
767
768    #[tokio::test]
769    async fn registry_single_source_discipline() {
770        let ctx = Context::new_root();
771        let registry = RegistryService::new();
772
773        struct FooPlugin;
774        impl Plugin for FooPlugin {
775            type Config = ();
776            type Provides = FooService;
777            fn apply(
778                &self,
779                _ctx: &Arc<Context>,
780                _cfg: Self::Config,
781            ) -> Result<Arc<Self::Provides>, CordisError> {
782                Ok(Arc::new(FooService(1)))
783            }
784        }
785
786        struct FooPlugin2;
787        impl Plugin for FooPlugin2 {
788            type Config = ();
789            type Provides = FooService;
790            fn apply(
791                &self,
792                _ctx: &Arc<Context>,
793                _cfg: Self::Config,
794            ) -> Result<Arc<Self::Provides>, CordisError> {
795                Ok(Arc::new(FooService(2)))
796            }
797        }
798
799        let fid1 = registry
800            .plugin(&ctx, FooPlugin, ())
801            .expect("first plugin ok");
802        assert!(registry.get_fiber(fid1).is_some());
803        let err = registry
804            .plugin(&ctx, FooPlugin2, ())
805            .expect_err("duplicate should fail");
806        assert!(err.to_string().contains("duplicate provider"));
807        // original still present
808        assert!(registry.get_fiber(fid1).is_some());
809    }
810
811    #[tokio::test]
812    async fn test_event_bus_dispatch_received() {
813        let ctx = Context::new_root();
814        let events = ctx.provide(EventsService::new());
815
816        // Register a listener
817        let received = Arc::new(Mutex::new(Vec::new()));
818        let received_clone = received.clone();
819        events.on("test.event".into(), move |payload| {
820            let r = received_clone.clone();
821            async move {
822                r.lock().push(payload.clone());
823                Ok(payload)
824            }
825        });
826
827        // Dispatch
828        let payload = serde_json::json!({"key": "value"});
829        events
830            .dispatch("test.event".into(), payload.clone(), Dispatch::Serial)
831            .await
832            .unwrap();
833
834        // Verify received
835        let msgs = received.lock();
836        assert_eq!(msgs.len(), 1);
837        assert_eq!(msgs[0]["key"], "value");
838    }
839
840    #[tokio::test]
841    async fn test_reactive_activation_deactivation() {
842        // Service A that fiber depends on
843        struct DepService;
844        impl Service for DepService {}
845
846        // Create root context with ReflectService
847        let ctx = Context::new_root();
848        ctx.provide(ReflectService::new());
849        let reflect = ctx.get::<ReflectService>().unwrap();
850        reflect.set_context(&ctx);
851
852        // Create fiber that injects DepService
853        let fiber = Arc::new(Fiber::new());
854        fiber.declare_inject::<DepService>();
855        let fid: FiberId = 100;
856        reflect.register_dependent(TypeId::of::<DepService>(), fid);
857        reflect.register_fiber(fid, fiber.clone(), TypeId::of::<DepService>());
858
859        // Initially Inactive (dep not provided)
860        fiber.refresh(&ctx).await;
861        assert!(matches!(fiber.state(), FiberState::Inactive { .. }));
862
863        // Provide DepService -> fiber should activate via notify cascade
864        ctx.provide(DepService);
865        // Give tokio a chance to run the spawned refresh
866        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
867        assert!(
868            matches!(fiber.state(), FiberState::Active { .. }),
869            "fiber should be Active after provide, got: {:?}",
870            fiber.state()
871        );
872
873        // Remove DepService -> fiber should deactivate via notify cascade
874        let _ = ctx.remove::<DepService>();
875        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
876        assert!(
877            matches!(fiber.state(), FiberState::Inactive { .. }),
878            "fiber should be Inactive after remove, got: {:?}",
879            fiber.state()
880        );
881    }
882
883    #[tokio::test]
884    async fn test_isolate_disjoint_namespaces() {
885        #[derive(Debug)]
886        struct ToolSvc(String);
887        impl Service for ToolSvc {}
888
889        let root = Context::new_root();
890
891        // Create two isolated contexts for different tenants
892        let ctx_a = root.isolate::<ToolSvc>("tenant_a");
893        ctx_a.provide(ToolSvc("tool_for_a".into()));
894
895        let ctx_b = root.isolate::<ToolSvc>("tenant_b");
896        ctx_b.provide(ToolSvc("tool_for_b".into()));
897
898        // Each tenant sees only its own service via get_isolated
899        let svc_a = ctx_a.get_isolated::<ToolSvc>("tenant_a");
900        assert!(svc_a.is_some());
901        assert_eq!(svc_a.unwrap().0, "tool_for_a");
902
903        let svc_b = ctx_b.get_isolated::<ToolSvc>("tenant_b");
904        assert!(svc_b.is_some());
905        assert_eq!(svc_b.unwrap().0, "tool_for_b");
906
907        // Cross-tenant access returns None
908        assert!(ctx_a.get_isolated::<ToolSvc>("tenant_b").is_none());
909        assert!(ctx_b.get_isolated::<ToolSvc>("tenant_a").is_none());
910
911        // Root has no isolated service
912        assert!(root.get_isolated::<ToolSvc>("tenant_a").is_none());
913        assert!(root.get_isolated::<ToolSvc>("tenant_b").is_none());
914    }
915
916    #[test]
917    fn bind_isolate_labels_provided_service_in_place() {
918        #[derive(Debug)]
919        struct ToolSvc(String);
920        impl Service for ToolSvc {}
921
922        let root = Context::new_root();
923        root.provide(ToolSvc("fleet".into()));
924        root.bind_isolate(TypeId::of::<ToolSvc>(), "tenant:acme");
925        let got = root
926            .get_isolated::<ToolSvc>("tenant:acme")
927            .expect("in-place isolate");
928        assert_eq!(got.0, "fleet");
929        assert!(root.get::<ToolSvc>().is_some());
930    }
931
932    #[tokio::test]
933    async fn test_intercept_overrides_get() {
934        #[derive(Debug)]
935        struct ModelSvc {
936            model: String,
937        }
938        impl Service for ModelSvc {}
939
940        let root = Context::new_root();
941        root.provide(ModelSvc {
942            model: "gpt-4".into(),
943        });
944
945        // Root returns the original
946        assert_eq!(root.get::<ModelSvc>().unwrap().model, "gpt-4");
947
948        // with_intercept creates a child context where get returns the override
949        let req_ctx = root.with_intercept(ModelSvc {
950            model: "gpt-4o-mini".into(),
951        });
952        assert_eq!(req_ctx.get::<ModelSvc>().unwrap().model, "gpt-4o-mini");
953
954        // Root remains unaffected
955        assert_eq!(root.get::<ModelSvc>().unwrap().model, "gpt-4");
956
957        // Stacking intercepts: innermost wins
958        let inner_ctx = req_ctx.intercept(ModelSvc {
959            model: "o1-preview".into(),
960        });
961        assert_eq!(inner_ctx.get::<ModelSvc>().unwrap().model, "o1-preview");
962        // Outer still sees its own override
963        assert_eq!(req_ctx.get::<ModelSvc>().unwrap().model, "gpt-4o-mini");
964    }
965
966    #[tokio::test]
967    async fn isolate_wins_over_same_type_intercept() {
968        #[derive(Debug)]
969        struct ToolSvc(String);
970        impl Service for ToolSvc {}
971
972        #[derive(Debug)]
973        struct OtherSvc(String);
974        impl Service for OtherSvc {}
975
976        let root = Context::new_root();
977        let child = root.isolate::<ToolSvc>("acme");
978        child.provide(ToolSvc("store".into()));
979
980        let intercepted = child.intercept(ToolSvc("override".into()));
981        assert_eq!(intercepted.get::<ToolSvc>().unwrap().0, "store");
982
983        let mixed = child.intercept(OtherSvc("override".into()));
984        assert_eq!(mixed.get::<OtherSvc>().unwrap().0, "override");
985        assert_eq!(mixed.get::<ToolSvc>().unwrap().0, "store");
986    }
987
988    #[tokio::test]
989    async fn inject_returns_immediately_when_already_provided() {
990        let ctx = Context::new_root();
991        ctx.provide(FooService(1));
992        let got = ctx.inject::<FooService>().await;
993        assert_eq!(got.name(), FooService(1).name());
994        assert_eq!(got.0, 1);
995    }
996
997    #[tokio::test]
998    async fn inject_waits_until_service_is_provided() {
999        let ctx = Context::new_root();
1000        let waiter = ctx.clone();
1001        let handle = tokio::spawn(async move { waiter.inject::<FooService>().await });
1002        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1003        ctx.provide(FooService(42));
1004        let got = tokio::time::timeout(std::time::Duration::from_millis(200), handle)
1005            .await
1006            .expect("inject should complete within 200ms")
1007            .expect("inject task should not panic");
1008        assert_eq!(got.0, 42);
1009    }
1010
1011    #[tokio::test]
1012    async fn inject_unblocks_via_reflect_notify() {
1013        let ctx = Context::new_root();
1014        ctx.provide(ReflectService::new());
1015        let reflect = ctx.get::<ReflectService>().unwrap();
1016        reflect.set_context(&ctx);
1017
1018        let waiter = ctx.clone();
1019        let handle = tokio::spawn(async move { waiter.inject::<FooService>().await });
1020        tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1021        ctx.provide(FooService(7));
1022        let got = tokio::time::timeout(std::time::Duration::from_millis(200), handle)
1023            .await
1024            .expect("inject should complete within 200ms via reflect notify")
1025            .expect("inject task should not panic");
1026        assert_eq!(got.0, 7);
1027    }
1028
1029    #[tokio::test]
1030    async fn test_production_style_reactive_cycle() {
1031        #[derive(Debug)]
1032        struct Probe;
1033        impl Service for Probe {}
1034
1035        let ctx = Context::new_root();
1036        ctx.provide(ReflectService::new());
1037        let reflect = ctx.get::<ReflectService>().unwrap();
1038        reflect.set_context(&ctx);
1039
1040        let f = Arc::new(Fiber::new());
1041        f.declare_inject::<Probe>();
1042        reflect.register_dependent(TypeId::of::<Probe>(), 777);
1043        reflect.register_fiber(777, f.clone(), TypeId::of::<Probe>());
1044
1045        // Initially Inactive: dep not yet provided
1046        f.refresh(&ctx).await;
1047        assert!(
1048            matches!(f.state(), FiberState::Inactive { .. }),
1049            "expected Inactive before provide, got {:?}",
1050            f.state()
1051        );
1052
1053        // Provide -> notify_with_ctx (synchronous, no sleeps) drives activation
1054        let _probe = ctx.provide(Probe);
1055        reflect.notify_with_ctx(TypeId::of::<Probe>(), &ctx).await;
1056        assert!(
1057            matches!(f.state(), FiberState::Active { .. }),
1058            "expected Active after provide, got {:?}",
1059            f.state()
1060        );
1061
1062        // Remove -> notify_with_ctx drives deactivation
1063        let _ = ctx.remove::<Probe>();
1064        reflect.notify_with_ctx(TypeId::of::<Probe>(), &ctx).await;
1065        assert!(
1066            matches!(f.state(), FiberState::Inactive { .. }),
1067            "expected Inactive after remove, got {:?}",
1068            f.state()
1069        );
1070    }
1071
1072    // -----------------------------------------------------------------------
1073    // EventsService dispatch parity with Cordis TS semantics
1074    // -----------------------------------------------------------------------
1075
1076    use std::sync::atomic::{AtomicUsize, Ordering};
1077
1078    #[tokio::test]
1079    async fn events_emit_fire_and_forget_and_broadcast() {
1080        let svc = EventsService::new();
1081        // Each handler signals completion via an mpsc channel after doing work.
1082        let (done_tx, mut done_rx) = tokio::sync::mpsc::channel::<()>(16);
1083        let mut bus_rx = svc.subscribe();
1084
1085        for i in 0..3 {
1086            let tx = done_tx.clone();
1087            svc.on("emit.test".into(), move |payload| {
1088                let tx = tx.clone();
1089                async move {
1090                    // simulate async work so dispatch must NOT await us
1091                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1092                    let _ = tx.send(()).await;
1093                    Ok(serde_json::json!({ "handler": i, "seen": payload }))
1094                }
1095            });
1096        }
1097
1098        let payload = serde_json::json!({ "n": 1 });
1099        let start = std::time::Instant::now();
1100        let out = svc
1101            .dispatch("emit.test".into(), payload.clone(), Dispatch::Emit)
1102            .await
1103            .unwrap();
1104        let dispatch_elapsed = start.elapsed();
1105
1106        // Emit returns immediately (fire-and-forget) with Null — it does NOT
1107        // await handler completion.
1108        assert_eq!(out, serde_json::Value::Null);
1109        assert!(
1110            dispatch_elapsed < std::time::Duration::from_millis(20),
1111            "emit returned after {:?} — should return immediately",
1112            dispatch_elapsed
1113        );
1114
1115        // The raw event+payload was broadcast on the bus.
1116        let (evt, bus_payload) =
1117            tokio::time::timeout(std::time::Duration::from_secs(1), bus_rx.recv())
1118                .await
1119                .expect("bus should broadcast")
1120                .expect("bus recv should be a value");
1121        assert_eq!(evt, "emit.test");
1122        assert_eq!(bus_payload, payload);
1123
1124        // Even though emit is fire-and-forget, every handler must still run to
1125        // completion before the test asserts.
1126        for _ in 0..3 {
1127            tokio::time::timeout(std::time::Duration::from_secs(1), done_rx.recv())
1128                .await
1129                .expect("handlers should complete")
1130                .expect("handler completion signal");
1131        }
1132    }
1133
1134    #[tokio::test]
1135    async fn events_emit_invokes_registered_handler_counter() {
1136        // Spec: a handler registered via `on()` actually RUNS on `Emit`. Prove it
1137        // with an `Arc<AtomicUsize>` counter that the handler increments, then poll
1138        // (sleep loop) until it is > 0.
1139        let svc = EventsService::new();
1140        let counter = Arc::new(AtomicUsize::new(0));
1141
1142        let c = counter.clone();
1143        svc.on("emit.counter".into(), move |payload| {
1144            let c = c.clone();
1145            async move {
1146                // Simulate a little async work so the spawn completes on the runtime.
1147                let n = payload.as_i64().unwrap_or(0);
1148                for _ in 0..n {
1149                    tokio::task::yield_now().await;
1150                }
1151                c.fetch_add(1, Ordering::SeqCst);
1152                Ok(serde_json::Value::Null)
1153            }
1154        });
1155
1156        let out = svc
1157            .dispatch("emit.counter".into(), serde_json::json!(5), Dispatch::Emit)
1158            .await
1159            .unwrap();
1160        assert_eq!(out, serde_json::Value::Null);
1161
1162        // Poll until the spawned handler has actually run (fire-and-forget means we
1163        // cannot await it directly).
1164        for _ in 0..100 {
1165            if counter.load(Ordering::SeqCst) > 0 {
1166                break;
1167            }
1168            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1169        }
1170        assert!(
1171            counter.load(Ordering::SeqCst) > 0,
1172            "emit handler should have run and incremented the counter"
1173        );
1174    }
1175
1176    #[tokio::test]
1177    async fn events_serial_threads_payload_in_order() {
1178        let svc = EventsService::new();
1179        let payload = serde_json::json!({ "n": 1 });
1180        let seen = Arc::new(Mutex::new(Vec::new()));
1181
1182        for tag in ["a", "b", "c"] {
1183            let seen = seen.clone();
1184            let tag = tag.to_string();
1185            svc.on("serial.test".into(), move |received| {
1186                let seen = seen.clone();
1187                let tag = tag.clone();
1188                async move {
1189                    seen.lock().push((tag, received));
1190                    Ok(serde_json::Value::Null)
1191                }
1192            });
1193        }
1194
1195        let out = svc
1196            .dispatch("serial.test".into(), payload.clone(), Dispatch::Serial)
1197            .await
1198            .unwrap();
1199
1200        // Serial handlers see the original payload, and an all-null chain preserves it.
1201        assert_eq!(out, payload);
1202        assert_eq!(
1203            seen.lock().clone(),
1204            vec![
1205                ("a".to_string(), payload.clone()),
1206                ("b".to_string(), payload.clone()),
1207                ("c".to_string(), payload),
1208            ]
1209        );
1210    }
1211
1212    #[tokio::test]
1213    async fn events_bail_stops_at_first_non_null_and_skips_later_handlers() {
1214        let svc = EventsService::new();
1215        let ran = Arc::new(AtomicUsize::new(0));
1216
1217        // Handler 1 returns Null → does not bail, chain continues.
1218        let h1 = ran.clone();
1219        svc.on("bail.test".into(), move |_payload| {
1220            let r = h1.clone();
1221            async move {
1222                r.fetch_add(1, Ordering::SeqCst);
1223                Ok(serde_json::Value::Null)
1224            }
1225        });
1226        // Handler 2 returns a non-null value → bails.
1227        let h2 = ran.clone();
1228        svc.on("bail.test".into(), move |_payload| {
1229            let r = h2.clone();
1230            async move {
1231                r.fetch_add(1, Ordering::SeqCst);
1232                Ok(serde_json::json!({ "bailed": true }))
1233            }
1234        });
1235        // Handler 3 must NOT run.
1236        let h3 = ran.clone();
1237        svc.on("bail.test".into(), move |_payload| {
1238            let r = h3.clone();
1239            async move {
1240                r.fetch_add(1, Ordering::SeqCst);
1241                Ok(serde_json::Value::Null)
1242            }
1243        });
1244
1245        let payload = serde_json::json!({ "n": 1 });
1246        let out = svc
1247            .dispatch("bail.test".into(), payload.clone(), Dispatch::Bail)
1248            .await
1249            .unwrap();
1250        assert_eq!(out, serde_json::json!({ "bailed": true }));
1251        // Only the first two handlers ran; handler 3 was skipped.
1252        assert_eq!(ran.load(Ordering::SeqCst), 2);
1253    }
1254
1255    #[tokio::test]
1256    async fn events_waterfall_handler_calls_next_and_receives_downstream_result() {
1257        let svc = EventsService::new();
1258        // Chain: outer wraps inner. The inner handler runs first during `next`,
1259        // then the outer transforms the downstream result.
1260        svc.on_waterfall("wf.next".into(), |payload, next| {
1261            let next = next;
1262            async move {
1263                let downstream = next(payload).await?;
1264                // The outer transforms what came back from downstream.
1265                let mut obj = downstream.as_object().cloned().unwrap_or_default();
1266                obj.insert("outer".into(), serde_json::json!(true));
1267                Ok(serde_json::Value::Object(obj))
1268            }
1269        });
1270        svc.on_waterfall("wf.next".into(), |payload, _next| async move {
1271            let mut obj = payload.as_object().cloned().unwrap_or_default();
1272            obj.insert("inner_seen".into(), serde_json::json!(payload.get("value")));
1273            Ok(serde_json::Value::Object(obj))
1274        });
1275
1276        let payload = serde_json::json!({ "value": 42 });
1277        let out = svc
1278            .dispatch("wf.next".into(), payload, Dispatch::Waterfall)
1279            .await
1280            .unwrap();
1281        let obj = out
1282            .as_object()
1283            .expect("waterfall output should be an object");
1284        // Inner ran (during next) and outer wrapped its result.
1285        assert_eq!(obj["inner_seen"], serde_json::json!(42));
1286        assert_eq!(obj["outer"], serde_json::json!(true));
1287    }
1288
1289    #[tokio::test]
1290    async fn events_waterfall_handler_short_circuits_skips_later_handlers() {
1291        let svc = EventsService::new();
1292        let ran = Arc::new(AtomicUsize::new(0));
1293
1294        // First handler short-circuits: does NOT call next.
1295        let h1 = ran.clone();
1296        svc.on_waterfall("wf.short".into(), move |_payload, _next| {
1297            let r = h1.clone();
1298            async move {
1299                r.fetch_add(1, Ordering::SeqCst);
1300                Ok(serde_json::json!({ "owned": true }))
1301            }
1302        });
1303        // Later handler must NOT run.
1304        let h2 = ran.clone();
1305        svc.on_waterfall("wf.short".into(), move |payload, next| {
1306            let r = h2.clone();
1307            async move {
1308                r.fetch_add(1, Ordering::SeqCst);
1309                next(payload).await
1310            }
1311        });
1312
1313        let payload = serde_json::json!({ "n": 1 });
1314        let out = svc
1315            .dispatch("wf.short".into(), payload, Dispatch::Waterfall)
1316            .await
1317            .unwrap();
1318        assert_eq!(out, serde_json::json!({ "owned": true }));
1319        // The later handler never ran.
1320        assert_eq!(ran.load(Ordering::SeqCst), 1);
1321    }
1322
1323    #[tokio::test]
1324    async fn events_waterfall_empty_chain_returns_payload_unchanged() {
1325        let svc = EventsService::new();
1326        let payload = serde_json::json!({ "n": 7 });
1327        let out = svc
1328            .dispatch("wf.empty".into(), payload.clone(), Dispatch::Waterfall)
1329            .await
1330            .unwrap();
1331        assert_eq!(out, payload);
1332    }
1333
1334    #[tokio::test]
1335    async fn events_parallel_propagates_aggregate_error() {
1336        let svc = EventsService::new();
1337        svc.on("par.test".into(), |_payload| async move {
1338            Ok(serde_json::json!({ "ok": 1 }))
1339        });
1340        svc.on("par.test".into(), |_payload| async move {
1341            Err(CordisError::Fiber("boom".into()))
1342        });
1343        svc.on("par.test".into(), |_payload| async move {
1344            Ok(serde_json::json!({ "ok": 2 }))
1345        });
1346
1347        let payload = serde_json::json!({ "n": 1 });
1348        let err = svc
1349            .dispatch("par.test".into(), payload, Dispatch::Parallel)
1350            .await
1351            .unwrap_err();
1352        assert!(
1353            err.to_string().contains("boom"),
1354            "parallel should propagate the handler error, got: {err}"
1355        );
1356    }
1357
1358    #[tokio::test]
1359    async fn events_parallel_returns_a_value_when_no_handler_errors() {
1360        let svc = EventsService::new();
1361        let payload = serde_json::json!({ "n": 1 });
1362        let seen = Arc::new(Mutex::new(Vec::new()));
1363
1364        for tag in ["a", "b"] {
1365            let seen = seen.clone();
1366            let tag = tag.to_string();
1367            svc.on("par2.test".into(), move |received| {
1368                let seen = seen.clone();
1369                let tag = tag.clone();
1370                async move {
1371                    seen.lock().push((tag, received));
1372                    Ok(serde_json::json!({ "handler": "complete" }))
1373                }
1374            });
1375        }
1376
1377        let out = svc
1378            .dispatch("par2.test".into(), payload.clone(), Dispatch::Parallel)
1379            .await
1380            .unwrap();
1381
1382        // Parallel waits for every handler, but successful dispatch returns null.
1383        assert_eq!(out, serde_json::Value::Null);
1384        let mut completed = seen.lock().clone();
1385        completed.sort_by(|left, right| left.0.cmp(&right.0));
1386        assert_eq!(
1387            completed,
1388            vec![
1389                ("a".to_string(), payload.clone()),
1390                ("b".to_string(), payload)
1391            ]
1392        );
1393    }
1394
1395    #[tokio::test(flavor = "multi_thread")]
1396    async fn notify_broadcasts_service_changed_event() {
1397        let ctx = Context::new_root();
1398        let events_handle = ctx.provide(EventsService::new());
1399        let reflect = ctx.provide(ReflectService::new());
1400        reflect.set_context(&ctx);
1401
1402        let mut rx = events_handle.subscribe();
1403        reflect.notify(TypeId::of::<u64>());
1404
1405        let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
1406        let mut seen = false;
1407        while std::time::Instant::now() < deadline {
1408            match rx.try_recv() {
1409                Ok((name, payload)) => {
1410                    assert_eq!(name, crate::events_catalog::ev::SERVICE_CHANGED);
1411                    // TypeId formats as a hash, not a name; just require presence.
1412                    assert!(
1413                        payload["type_id"].as_str().unwrap().starts_with("TypeId("),
1414                        "payload should identify the changed type: {payload}"
1415                    );
1416                    seen = true;
1417                    break;
1418                }
1419                Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {
1420                    tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1421                }
1422                Err(e) => panic!("unexpected broadcast error: {e}"),
1423            }
1424        }
1425        assert!(
1426            seen,
1427            "service.changed broadcast not observed within timeout"
1428        );
1429    }
1430
1431    /// ReflectService bookkeeping must not leak disposed fibers: pruning
1432    /// drops `fibers` / `fiber_provides` entries whose disposal already ran
1433    /// (opportunistic sweep at the top of every notify), while live and
1434    /// Failed fibers stay tracked.
1435    #[tokio::test]
1436    async fn reflect_prune_disposed_drops_dead_fibers_only() {
1437        let ctx = Context::new_root();
1438        let reflect = ReflectService::new();
1439
1440        let dead = Arc::new(Fiber::new());
1441        let live = Arc::new(Fiber::new());
1442        let failed = Arc::new(Fiber::new());
1443        failed.set_state(crate::FiberState::Failed { error: None });
1444
1445        reflect.register_fiber(1, dead.clone(), TypeId::of::<u64>());
1446        reflect.register_fiber(2, live.clone(), TypeId::of::<u64>());
1447        reflect.register_fiber(3, failed.clone(), TypeId::of::<u64>());
1448
1449        // Nothing pruned before any disposal.
1450        assert_eq!(reflect.prune_disposed(), 0);
1451
1452        let _ = dead.dispose().await;
1453        assert_eq!(
1454            reflect.prune_disposed(),
1455            1,
1456            "exactly the disposed fiber is dropped"
1457        );
1458        // Live + Failed remain; the disposed one is gone.
1459        assert!(matches!(live.state(), crate::FiberState::Inactive { .. }));
1460        assert!(matches!(failed.state(), crate::FiberState::Failed { .. }));
1461
1462        // The opportunistic path: notify() sweeps again before walking.
1463        reflect.notify(TypeId::of::<u64>());
1464    }
1465}