Skip to main content

cordis/
registry.rs

1use std::any::TypeId;
2use std::collections::HashMap;
3use std::sync::Arc;
4
5use parking_lot::{Mutex, RwLock};
6use serde::{de::DeserializeOwned, Serialize};
7
8use crate::{Context, CordisError, Fiber, FiberId, Service, Symbol};
9
10/// A plugin is a declarative unit of configuration that can provide a service
11/// into a context. The registry calls apply to create the service value and
12/// then inserts it into the context with single-source discipline. The
13/// returned Arc is the live service instance that callers retrieve through
14/// ctx.get. Plain prose is used here to keep the docs readable and to avoid
15/// clever abstractions that would hide the simple flow of create then provide.
16pub trait Plugin: Send + Sync + 'static {
17    type Config: Serialize + DeserializeOwned + Send + Sync + 'static;
18    type Provides: Service;
19    fn apply(
20        &self,
21        ctx: &Arc<Context>,
22        config: Self::Config,
23    ) -> Result<Arc<Self::Provides>, CordisError>;
24}
25
26/// Composable readiness predicate for [`RegistryService::register_with_readiness`]
27/// (C2 `ready_when`).
28///
29/// A barrier is a shared closure consulted by the lifecycle on every
30/// activation pass. While it reports `false` the fiber rests inspectable
31/// `Pending` — quiet waiting, NOT failure: availability predicates
32/// ([`Service::check`]) are the loud complement that rests
33/// `Failed{error: "availability predicate rejected service"}` and converge
34/// through refreshes; a readiness gate simply holds the fiber out of
35/// service until the environment it observes turns ready.
36///
37/// Re-kick contract: a gated fiber re-evaluates whenever its lifecycle is
38/// driven — in practice via the existing round-5 observer fan-out, because
39/// any managed fiber settling (`Active`/`Inactive`/`Pending`/`Failed`) ends
40/// in provider/withdrawal notifies that BFS-refresh dependents. Barriers
41/// therefore observe plain context facts (`ctx.get::<T>().is_some()`,
42/// versions, config values) rather than registering their own watchers.
43/// Shared readiness predicate handle.
44type ReadinessPredicate = Arc<dyn Fn(&Arc<Context>) -> bool + Send + Sync>;
45
46#[derive(Clone)]
47pub struct ReadinessBarrier {
48    inner: ReadinessPredicate,
49    /// TypeIds whose provider settlements re-kick the gated fiber.
50    keys: Vec<TypeId>,
51}
52
53impl ReadinessBarrier {
54    /// Wrap one readiness predicate.
55    pub fn new(ready: impl Fn(&Arc<Context>) -> bool + Send + Sync + 'static) -> Self {
56        Self {
57            inner: Arc::new(ready),
58            keys: Vec::new(),
59        }
60    }
61
62    /// The declared watch keys for re-kick fan-out registration.
63    pub fn watched_type_ids(&self) -> &[TypeId] {
64        &self.keys
65    }
66
67    /// Evaluate the composed predicate against a context.
68    pub fn is_ready(&self, ctx: &Arc<Context>) -> bool {
69        (self.inner)(ctx)
70    }
71
72    /// AND-composition helper (`with_readiness`): the combined barrier is
73    /// ready only when BOTH operands report ready. Short-circuits on the
74    /// first closed half.
75    pub fn and(self, other: ReadinessBarrier) -> ReadinessBarrier {
76        let pair = (self.inner, other.inner);
77        ReadinessBarrier::new(move |ctx| (pair.0)(ctx) && (pair.1)(ctx))
78            .watching(self.keys.iter().chain(other.keys.iter()).copied())
79    }
80
81    /// Declare the `TypeId`s whose provider settlements should re-kick a
82    /// fiber gated by this barrier (see
83    /// [`RegistryService::register_with_readiness`]). Composition unions
84    /// both sides.
85    pub fn watching(
86        mut self,
87        keys: impl IntoIterator<Item = TypeId>,
88    ) -> ReadinessBarrier {
89        self.keys.extend(keys);
90        self
91    }
92}
93
94/// AND-composition helper (`with_readiness`): combine any number of
95/// readiness predicates into one [`ReadinessBarrier`] that is ready only
96/// when every operand is ready. `with_readiness([a, b, c])` reads as
97/// "ready when a AND b AND c"; the empty slice is vacuously ready.
98pub fn with_readiness(
99    barriers: impl IntoIterator<Item = ReadinessBarrier>,
100) -> ReadinessBarrier {
101    let mut combined: Option<ReadinessBarrier> = None;
102    for barrier in barriers {
103        combined = Some(match combined {
104            None => barrier,
105            Some(acc) => acc.and(barrier),
106        });
107    }
108    combined.unwrap_or_else(|| ReadinessBarrier::new(|_ctx| true))
109}
110
111impl std::fmt::Debug for ReadinessBarrier {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.debug_struct("ReadinessBarrier").finish_non_exhaustive()
114    }
115}
116
117/// RegistryService tracks fibers and enforces that only one fiber may provide
118/// a given TypeId inside the same isolate realm. If two registrations try to
119/// provide the same service type while their isolate labels overlap, the
120/// second registration fails with a configuration error that contains the
121/// phrase duplicate provider for and the debug form of the TypeId. Different
122/// isolate labels are allowed to provide the same TypeId because they live in
123/// disjoint realms, which is how multi-tenant tool and agent isolation is
124/// implemented. Fibers are stored by id so that callers can inspect or remove
125/// them later, and the provided map is keyed by the pair of TypeId and
126/// isolate label.
127pub struct RegistryService {
128    fibers: RwLock<HashMap<FiberId, Arc<Fiber>>>,
129    provided: RwLock<HashMap<(TypeId, Option<Symbol>), FiberId>>,
130    /// Registration context per tracked fiber, used to resolve which isolate
131    /// realm a consumer was registered against (guarded withdrawal).
132    realms: RwLock<HashMap<FiberId, std::sync::Weak<Context>>>,
133    next_id: Mutex<FiberId>,
134}
135
136impl RegistryService {
137    pub fn new() -> Self {
138        Self {
139            fibers: RwLock::new(HashMap::new()),
140            provided: RwLock::new(HashMap::new()),
141            realms: RwLock::new(HashMap::new()),
142            next_id: Mutex::new(1),
143        }
144    }
145
146    /// Count active consumer fibers that currently resolve the provider key
147    /// `(TypeId, isolate label)` through this registry.
148    ///
149    /// Reliance is derived live rather than seeded incrementally: a tracked
150    /// fiber counts as a consumer of `key` when it is Active, was registered
151    /// against the same isolate realm as the key's label, declares an inject
152    /// on the key's TypeId, and is not the provider fiber itself. Late
153    /// `declare_inject` calls therefore count immediately, retired or failed
154    /// consumers never block a withdrawal, and no stale bookkeeping can
155    /// accumulate.
156    pub fn reliance_count(&self, key: &(TypeId, Option<Symbol>)) -> usize {
157        let fibers = self.fibers.read();
158        let mut consumers = 0;
159        for (fid, fiber) in fibers.iter() {
160            if !matches!(fiber.state(), crate::FiberState::Active { .. }) {
161                continue;
162            }
163            // The provider itself never counts as its own consumer.
164            if Some(*fid) == self.provided.read().get(key).copied() {
165                continue;
166            }
167            // Realm check: the consumer must resolve the injected type in the
168            // same isolate realm the provider serves. `register` records each
169            // fiber's registration context, so its `isolate_label` view is the
170            // authoritative realm; loader-tracked fibers (no recorded context)
171            // cannot resolve realms and are skipped.
172            let Some(reg_ctx) = self
173                .realms
174                .read()
175                .get(fid)
176                .and_then(std::sync::Weak::upgrade)
177            else {
178                continue;
179            };
180            if reg_ctx.isolate_label(key.0) != key.1 {
181                continue;
182            }
183            for inject_tid in fiber.injected_type_ids() {
184                if inject_tid == key.0 {
185                    consumers += 1;
186                    break;
187                }
188            }
189        }
190        consumers
191    }
192
193    fn next_fiber_id(&self) -> FiberId {
194        let mut guard = self.next_id.lock();
195        let id = *guard;
196        *guard += 1;
197        id
198    }
199
200    /// Register a plugin and provide its service into the context. The call
201    /// first checks the single-source map for an existing provider with the
202    /// same TypeId and overlapping isolate realm. Overlap means the isolate
203    /// labels are equal, including both being None for the root realm. If a
204    /// duplicate is found the function returns a configuration error. Otherwise
205    /// it calls the plugin to build the service, inserts the service into the
206    /// context, creates a fiber to represent the registration, and records the
207    /// mapping.
208    ///
209    /// Availability predicates: the plugin factory's product is consulted via
210    /// [`Service::check`] before it is provided. A not-ready instance rests
211    /// the fiber as inspectable `Failed { error: "availability predicate
212    /// rejected service" }` instead of `Active`; registration itself stays
213    /// non-throwing because register-before-ready is a supported transient
214    /// that later refreshes converge (see the metatheory confluence legs).
215    pub fn register<P: Plugin>(
216        &self,
217        ctx: &Arc<Context>,
218        plugin: P,
219        config: P::Config,
220    ) -> Result<FiberId, CordisError> {
221        let tid = TypeId::of::<P::Provides>();
222        let isolate = ctx.isolate_label(tid);
223        let key = (tid, isolate);
224        // Decide staleness under the read guard only — the guard is a
225        // scrutinee temporary that would otherwise outlive the if-let body,
226        // deadlocking the `provided.write()` below against its own reader.
227        let active_conflict = match self.provided.read().get(&key).copied() {
228            Some(existing) => self
229                .fibers
230                .read()
231                .get(&existing)
232                .map(|fiber| {
233                    matches!(
234                        fiber.state(),
235                        crate::FiberState::Active { .. }
236                            | crate::FiberState::Loading
237                            | crate::FiberState::Reloading
238                    )
239                })
240                .unwrap_or(false),
241            None => false,
242        };
243        if active_conflict {
244            return Err(CordisError::DuplicateProvider {
245                name: format!("{tid:?}"),
246                owner: format!("isolate realm {:?}", ctx.isolate_label(tid)),
247            });
248        }
249        // A non-conflicting entry is stale (retired/disposed/failed provider)
250        // and must not block the fresh registration.
251        self.provided.write().remove(&key);
252
253        let fid = self.next_fiber_id();
254        let fiber = Arc::new(Fiber::new());
255        fiber.set_state(crate::FiberState::Loading);
256        fiber.set_reload_context(ctx);
257        fiber.set_id(fid);
258        self.fibers.write().insert(fid, fiber.clone());
259        self.realms.write().insert(fid, Arc::downgrade(ctx));
260
261        let config_value = serde_json::to_value(&config).map_err(|error| {
262            let message = format!("cannot serialize plugin config: {error}");
263            fiber.set_state(crate::FiberState::Failed {
264                error: Some(message.clone()),
265            });
266            self.wire_failed_registration(ctx, fid, &fiber, tid);
267            CordisError::Configuration(message)
268        })?;
269        // C1 intercept meta-events: stage the raw config so every refresh
270        // pass re-resolves the EFFECTIVE config from a single source.
271        fiber.set_raw_config(config_value.clone());
272        // C1 `internal/config` covers the ACTIVATION path too, not just
273        // refresh passes: resolve the effective config ONCE here so the very
274        // first runner pass below applies the intercepted configuration. A
275        // chain error fails the activation (`Failed`), mirroring refresh.
276        if let Some(events) = ctx.get::<crate::EventsService>() {
277            match crate::events::blocking_intercept_config(&events, config_value.clone()) {
278                Ok(effective) => {
279                    if !effective.is_null() {
280                        fiber.stage_effective_config(effective);
281                    }
282                }
283                Err(error) => {
284                    fiber.set_state(crate::FiberState::Failed {
285                        error: Some(error.to_string()),
286                    });
287                    self.wire_failed_registration(ctx, fid, &fiber, tid);
288                    return Err(error);
289                }
290            }
291        }
292        let plugin = Arc::new(plugin);
293        let weak_fiber = Arc::downgrade(&fiber);
294        fiber.set_reload_runner(Box::new(move |ctx| {
295            // C1 intercept meta-events: the interception point stages the
296            // effective config for this pass; without one this is the raw
297            // config and the path is byte-identical to the legacy runner.
298            // (The weak handle is upgraded first so a dropped registration
299            // fiber still falls back to the raw config instead of erroring.)
300            let cfg_raw = weak_fiber
301                .upgrade()
302                .and_then(|owner| owner.effective_config_override())
303                .unwrap_or_else(|| config_value.clone());
304            let config =
305                serde_json::from_value::<P::Config>(cfg_raw).map_err(|error| {
306                    CordisError::Configuration(format!("cannot deserialize plugin config: {error}"))
307                })?;
308            let owner = weak_fiber
309                .upgrade()
310                .ok_or_else(|| CordisError::Fiber("registration fiber was dropped".into()))?;
311            // Panic containment: a panicking plugin factory must not tear the
312            // host down. `AssertUnwindSafe` is required — the closure borrows
313            // `plugin` and the deserialized `config` by reference, which the
314            // unwinding cannot leave observably broken here because every
315            // captured value is dropped on unwind and the fiber's state is
316            // set to `Failed` below (ledger row #1: inspectable terminal
317            // state).
318            let applied = crate::hmr::catch_plugin_panic(std::panic::AssertUnwindSafe(|| {
319                ctx.with_provider_fiber(&owner, || plugin.apply(ctx, config))
320            }));
321            let applied = match applied {
322                Ok(applied) => applied,
323                Err(payload) => {
324                    return Err(CordisError::Fiber(format!(
325                        "plugin factory panicked: {payload}"
326                    )))
327                }
328            };
329            let provides = applied?;
330            let healthy = provides.check();
331            ctx.provide_on_fiber(provides, &owner);
332            Ok(healthy)
333        }));
334
335        let healthy = match fiber.run_runner(ctx) {
336            Ok(healthy) => healthy,
337            Err(error) => {
338                fiber.set_state(crate::FiberState::Failed {
339                    error: Some(error.to_string()),
340                });
341                // Metatheory delta #2 (resolved): a failed factory still
342                // enters the bookkeeping graph. Dependents of the attempted
343                // key observe the provider loss through the same notify path
344                // successful registrations use, and the Failed fiber stays
345                // inspectable via get_fiber.
346                self.wire_failed_registration(ctx, fid, &fiber, tid);
347                return Err(error);
348            }
349        };
350        // Availability predicate: the reload runner already consulted
351        // `provides.check()` BEFORE the value was provided, so an unready
352        // instance was never visible to consumers. Registration stays
353        // non-throwing (register-before-ready is a supported transient that
354        // later refreshes converge); the fiber rests as an inspectable
355        // `Failed` naming the rejection instead of a bare `Inactive`, so
356        // operators see WHY it is down.
357        let epoch = fiber.compute_epoch(ctx);
358        fiber.set_epoch(epoch.clone());
359        // NOTE: `mark_applied` is intentionally NOT called here. Reactive
360        // `Pending` eligibility requires a FULLY-SATISFIED refresh pass (all
361        // declared injects available), which registration cannot prove — a
362        // factory may succeed while its declares are still unserved. The
363        // flag is set by [`crate::Fiber::refresh`] on its first all-green
364        // pass.
365        fiber.set_state(if healthy {
366            crate::FiberState::Active { epoch }
367        } else {
368            crate::FiberState::Failed {
369                error: Some("availability predicate rejected service".into()),
370            }
371        });
372        self.provided.write().insert(key, fid);
373
374        if let Some(reflect) = ctx.get::<crate::ReflectService>() {
375            for dependency in fiber.injected_type_ids() {
376                reflect.register_dependent(dependency, fid);
377            }
378            let _ = reflect.ensure_notifier(tid);
379            reflect.register_fiber(fid, fiber.clone(), tid);
380        }
381        Ok(fid)
382    }
383
384    /// Register a plugin with a C2 `ready_when` readiness gate.
385    ///
386    /// Identical to [`Self::register`] except the fiber carries a
387    /// [`ReadinessBarrier`]: while the composed predicate reports not-ready
388    /// the lifecycle rests the fiber as inspectable
389    /// [`crate::FiberState::Pending`] (quiet waiting — never `Failed`), and
390    /// every subsequent refresh/re-kick re-evaluates it. The factory still
391    /// runs once at registration (so config errors surface immediately);
392    /// a closed gate simply keeps the produced service OUT of consumer
393    /// reach until the gate opens, because strict `ctx.get` refuses values
394    /// owned by non-`Active` fibers.
395    pub fn register_with_readiness<P: Plugin>(
396        &self,
397        ctx: &Arc<Context>,
398        plugin: P,
399        config: P::Config,
400        ready_when: ReadinessBarrier,
401    ) -> Result<FiberId, CordisError> {
402        let fid = self.register(ctx, plugin, config)?;
403        if let Some(fiber) = self.get_fiber(fid) {
404            // Re-kick wiring: the gate may observe provider facts beyond the
405            // fiber's own declared injects. Register the fiber against the
406            // barrier's declared watch keys so any settle on those types —
407            // provide or withdrawal through ReflectService — BFS-refreshes
408            // this fiber too.
409            for tid in ready_when.watched_type_ids().iter().copied() {
410                if let Some(reflect) = ctx.get::<crate::ReflectService>() {
411                    reflect.register_dependent(tid, fid);
412                    let _ = reflect.ensure_notifier(tid);
413                }
414            }
415            fiber.set_readiness_gate(ready_when);
416            // Re-enter the lifecycle so the freshly-installed gate decides
417            // the resting state right away instead of waiting for an
418            // external kick. Registration left the fiber `Active`/`Failed`;
419            // the gate consult runs inside the guarded transition.
420            tokio::spawn({
421                let fiber = fiber.clone();
422                let ctx = ctx.clone();
423                async move { fiber.refresh(&ctx).await }
424            });
425        }
426        Ok(fid)
427    }
428
429    /// Bookkeeping for a registration that ended in `Failed`.
430    ///
431    /// The fiber keeps its terminal visible state but must not vanish from
432    /// the graph: it stays tracked under its id, is registered with
433    /// [`crate::ReflectService`] against the attempted provider key, and a
434    /// notify on that key fans out so dependents observe the provider loss
435    /// reactively. The `provided` slot stays vacant — a later successful
436    /// registration of the same key therefore allocates a fresh fiber id
437    /// instead of being refused as a duplicate.
438    fn wire_failed_registration(
439        &self,
440        ctx: &Arc<Context>,
441        fid: FiberId,
442        fiber: &Arc<Fiber>,
443        tid: TypeId,
444    ) {
445        if let Some(reflect) = ctx.get::<crate::ReflectService>() {
446            for dependency in fiber.injected_type_ids() {
447                reflect.register_dependent(dependency, fid);
448            }
449            let _ = reflect.ensure_notifier(tid);
450            reflect.register_fiber(fid, fiber.clone(), tid);
451            reflect.notify(tid);
452        }
453    }
454
455    /// Alias for register that matches the historical name used in the
456    /// Cordis paper and earlier spike code. New code can use register, old
457    /// code can keep calling plugin, both do the same isolate-aware check.
458    pub fn plugin<P: Plugin>(
459        &self,
460        ctx: &Arc<Context>,
461        plugin: P,
462        config: P::Config,
463    ) -> Result<FiberId, CordisError> {
464        self.register(ctx, plugin, config)
465    }
466
467    /// Track an externally-created registration fiber under a fresh id.
468    ///
469    /// The loader uses this so plugin-factory fibers created outside
470    /// [`RegistryService::register`](Self::register) still resolve through
471    /// [`Self::get_fiber`] for retirement/disposal.
472    pub fn track_fiber(&self, fiber: Arc<Fiber>) -> FiberId {
473        let fid = self.next_fiber_id();
474        self.fibers.write().insert(fid, fiber);
475        fid
476    }
477
478    /// Drop tracking entries for fibers whose disposal already ran.
479    ///
480    /// A fiber is prunable only when [`Fiber::is_disposed`] is true (the
481    /// `disposed` flag `Fiber::dispose` sets). Every other state is kept:
482    /// `Failed{error}` is inspectable by design (ledger row #1), and
483    /// `Inactive`/`Active`/transitional fibers are live bookkeeping. The
484    /// matching `provided` slot and realm record are cleared alongside, so a
485    /// fresh registration of the same key never sees a stale conflict.
486    pub fn prune_disposed(&self) -> usize {
487        let disposed: Vec<FiberId> = self
488            .fibers
489            .read()
490            .iter()
491            .filter(|(_, fiber)| fiber.is_disposed())
492            .map(|(fid, _)| *fid)
493            .collect();
494        let mut removed = 0;
495        for fid in disposed {
496            if self.remove(fid).is_some() {
497                removed += 1;
498            }
499        }
500        removed
501    }
502
503    pub fn get_fiber(&self, id: FiberId) -> Option<Arc<Fiber>> {
504        // Inspectable-by-design read: no implicit pruning. Disposed fibers
505        // stay resolvable here until an explicit/opportunistic
506        // `prune_disposed()` runs, so post-dispose assertions and refreshes
507        // keep working.
508        self.fibers.read().get(&id).cloned()
509    }
510
511    pub fn remove(&self, id: FiberId) -> Option<Arc<Fiber>> {
512        let fiber = self.fibers.write().remove(&id)?;
513        let mut provided = self.provided.write();
514        provided.retain(|_, v| *v != id);
515        drop(provided);
516        self.realms.write().remove(&id);
517        Some(fiber)
518    }
519
520    /// Record the registration context for an externally-created fiber
521    /// (loader-tracked registrations), so guarded-withdrawal realm checks can
522    /// resolve its isolate realm.
523    pub fn track_fiber_in_realm(&self, fid: FiberId, ctx: &Arc<Context>) {
524        self.realms.write().insert(fid, Arc::downgrade(ctx));
525    }
526
527    /// Resolved provider fiber ids currently serving the given service types
528    /// in `ctx`'s isolate realms (C2 cascade batching lookup).
529    pub fn provider_fibers_for(&self, ctx: &Arc<Context>, tids: &[TypeId]) -> Vec<u64> {
530        let provided = self.provided.read();
531        tids.iter()
532            .filter_map(|tid| {
533                let isolate = ctx.isolate_label(*tid);
534                provided.get(&(*tid, isolate)).copied()
535            })
536            .collect()
537    }
538
539    /// The service types whose live provider slot is owned by `fid`
540    /// (C2 cascade batching: post-settle re-kick fan-out).
541    pub fn provided_types_of_fiber(&self, fid: FiberId) -> Vec<TypeId> {
542        self.provided
543            .read()
544            .iter()
545            .filter(|(_, owner)| **owner == fid)
546            .map(|(key, _)| key.0)
547            .collect()
548    }
549
550    /// Snapshot of every currently tracked fiber id (introspection surface).
551    pub fn tracked_ids(&self) -> Vec<FiberId> {
552        self.fibers.read().keys().copied().collect()
553    }
554
555    pub fn len(&self) -> usize {
556        // Opportunistic dead-fiber sweep: every cheap size probe also drops
557        // entries whose disposal already ran.
558        self.prune_disposed();
559        self.fibers.read().len()
560    }
561
562    pub fn is_empty(&self) -> bool {
563        self.fibers.read().is_empty()
564    }
565}
566
567impl Default for RegistryService {
568    fn default() -> Self {
569        Self::new()
570    }
571}
572
573impl Service for RegistryService {}
574
575// Static registration for compile-time plugins. The inventory crate collects
576// CordisInventory entries at link time, while linkme offers a similar
577// distributed slice. Both are optional and off by default for the core crate,
578// and the registry itself is the real runtime surface that enforces
579// single-source discipline. RegistryService is submitted once from lib.rs.
580
581#[cfg(feature = "linkme")]
582#[linkme::distributed_slice]
583pub static REGISTRY_PLUGINS: [fn(&Arc<Context>) -> Result<FiberId, CordisError>];
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use crate::{Context, FiberState, ReflectService, Service};
589
590    #[derive(Debug)]
591    struct FooService(pub i32);
592    impl Service for FooService {}
593
594    #[derive(Debug)]
595    struct BarService(pub i32);
596    impl Service for BarService {}
597
598    struct FooPlugin;
599    impl Plugin for FooPlugin {
600        type Config = ();
601        type Provides = FooService;
602        fn apply(
603            &self,
604            _ctx: &Arc<Context>,
605            _cfg: Self::Config,
606        ) -> Result<Arc<Self::Provides>, CordisError> {
607            Ok(Arc::new(FooService(1)))
608        }
609    }
610
611    struct FooPlugin2;
612    impl Plugin for FooPlugin2 {
613        type Config = ();
614        type Provides = FooService;
615        fn apply(
616            &self,
617            _ctx: &Arc<Context>,
618            _cfg: Self::Config,
619        ) -> Result<Arc<Self::Provides>, CordisError> {
620            Ok(Arc::new(FooService(2)))
621        }
622    }
623
624    struct BarPlugin;
625    impl Plugin for BarPlugin {
626        type Config = ();
627        type Provides = BarService;
628        fn apply(
629            &self,
630            _ctx: &Arc<Context>,
631            _cfg: Self::Config,
632        ) -> Result<Arc<Self::Provides>, CordisError> {
633            Ok(Arc::new(BarService(99)))
634        }
635    }
636
637    #[test]
638    fn duplicate_provider_rejected() {
639        let ctx = Context::new_root();
640        let registry = RegistryService::new();
641        let fid1 = registry
642            .register(&ctx, FooPlugin, ())
643            .expect("first registration should succeed");
644        assert!(registry.get_fiber(fid1).is_some());
645        // Second plugin providing same TypeId in same isolate realm must fail.
646        let err = registry
647            .register(&ctx, FooPlugin2, ())
648            .expect_err("duplicate provider should be rejected");
649        assert!(
650            err.to_string().contains("duplicate provider for"),
651            "error should mention duplicate provider, got {err}"
652        );
653        // Original still retrievable.
654        assert!(registry.get_fiber(fid1).is_some());
655        // Service still retrievable through context.
656        let svc = ctx.get::<FooService>().expect("service should be present");
657        assert_eq!(svc.0, 1);
658    }
659
660    struct FailingPlugin;
661    impl Plugin for FailingPlugin {
662        type Config = ();
663        type Provides = FooService;
664        fn apply(
665            &self,
666            _ctx: &Arc<Context>,
667            _cfg: Self::Config,
668        ) -> Result<Arc<Self::Provides>, CordisError> {
669            Err(CordisError::Configuration("intentional failure".into()))
670        }
671    }
672
673    #[test]
674    fn failed_plugin_transitions_tracked_fiber_to_failed_with_error() {
675        use crate::FiberState;
676        let ctx = Context::new_root();
677        let registry = RegistryService::new();
678        // A failing plugin is rejected, but the registry still tracked a fiber
679        // in the Failed state carrying the error.
680        let err = registry
681            .register(&ctx, FailingPlugin, ())
682            .expect_err("failing plugin should be rejected");
683        assert!(err.to_string().contains("intentional failure"));
684        let existing = registry
685            .get_fiber(1)
686            .expect("failed fiber should be tracked");
687        match existing.state() {
688            FiberState::Failed { error } => {
689                assert!(error
690                    .as_deref()
691                    .unwrap_or("")
692                    .contains("intentional failure"));
693            }
694            other => panic!("expected Failed state, got {other:?}"),
695        }
696        // No service was provided for the failing plugin.
697        assert!(ctx.get::<FooService>().is_none());
698    }
699
700    struct PanickingPlugin;
701    impl Plugin for PanickingPlugin {
702        type Config = ();
703        type Provides = FooService;
704        fn apply(
705            &self,
706            _ctx: &Arc<Context>,
707            _cfg: Self::Config,
708        ) -> Result<Arc<Self::Provides>, CordisError> {
709            panic!("factory exploded");
710        }
711    }
712
713    /// A panicking factory must not abort the host: register converts the
714    /// unwind into an inspectable `Failed` fiber carrying "factory panicked",
715    /// keeps the provider key unserved, and notifies dependents through the
716    /// same path as any other failed registration.
717    #[tokio::test]
718    async fn panicking_factory_registers_failed_and_notifies_dependents() {
719        let ctx = Context::new_root();
720        ctx.provide(ReflectService::new());
721        if let Some(reflect) = ctx.get::<ReflectService>() {
722            reflect.set_context(&ctx);
723        }
724        let registry = RegistryService::new();
725
726        let err = registry
727            .register(&ctx, PanickingPlugin, ())
728            .expect_err("panicking factory should be rejected");
729        match &err {
730            CordisError::Fiber(message) => {
731                assert!(
732                    message.contains("plugin factory panicked"),
733                    "unexpected error text: {message}"
734                );
735                assert!(message.contains("factory exploded"));
736            }
737            other => panic!("expected Fiber error, got {other:?}"),
738        }
739        // The fiber stays inspectable with the panic message (ledger row #1).
740        let fiber = registry.get_fiber(1).expect("failed fiber is tracked");
741        match fiber.state() {
742            FiberState::Failed { error } => {
743                let error = error.as_deref().unwrap_or("");
744                assert!(error.contains("factory panicked"), "got: {error}");
745                assert!(error.contains("factory exploded"));
746            }
747            other => panic!("expected Failed state, got {other:?}"),
748        }
749        // No service was provided; the key stays unserved for a fresh try.
750        assert!(ctx.get::<FooService>().is_none());
751        let fid_retry = registry
752            .register(&ctx, FooPlugin, ())
753            .expect("fresh registration of the same key after a panic");
754        assert!(matches!(
755            registry.get_fiber(fid_retry).unwrap().state(),
756            FiberState::Active { .. }
757        ));
758
759        // Dependents observe the loss through the reactive notify path:
760        // declare an inject against the attempted key after the failure and
761        // refresh — it resolves only once the passing registration lands.
762        let dep_fid = registry
763            .register(&ctx, BarPlugin, ())
764            .expect("dependent registers without its dependency");
765        let dependent = registry.get_fiber(dep_fid).unwrap();
766        dependent.declare_inject::<FooService>();
767        dependent.refresh(&ctx).await;
768        assert!(matches!(dependent.state(), FiberState::Active { .. }));
769    }
770
771    #[test]
772    fn re_registered_good_plugin_moves_fiber_to_active() {
773        use crate::FiberState;
774        let ctx = Context::new_root();
775        let registry = RegistryService::new();
776        let _ = registry
777            .register(&ctx, FailingPlugin, ())
778            .expect_err("failing plugin should be rejected");
779        let original = registry.get_fiber(1).unwrap();
780        assert!(matches!(original.state(), FiberState::Failed { .. }));
781
782        // A subsequent good registration (different type) is a new fiber, Active.
783        let fid_ok = registry
784            .register(&ctx, BarPlugin, ())
785            .expect("good plugin should register");
786        let ok = registry.get_fiber(fid_ok).expect("good fiber");
787        match ok.state() {
788            FiberState::Active { .. } => {}
789            other => panic!("expected Active state, got {other:?}"),
790        }
791    }
792
793    #[test]
794    fn different_isolates_allowed() {
795        let root = Context::new_root();
796        let registry = RegistryService::new();
797        // First provider in root realm.
798        let fid_root = registry
799            .register(&root, FooPlugin, ())
800            .expect("root registration ok");
801        assert!(registry.get_fiber(fid_root).is_some());
802        // Isolate for FooService with label tenant acme.
803        let tenant_a = root.isolate::<FooService>("tenant:acme");
804        let fid_a = registry
805            .register(&tenant_a, FooPlugin2, ())
806            .expect("different isolate should be allowed");
807        assert!(registry.get_fiber(fid_a).is_some());
808        // Yet a second registration in the same tenant isolate must fail.
809        struct FooPlugin3;
810        impl Plugin for FooPlugin3 {
811            type Config = ();
812            type Provides = FooService;
813            fn apply(
814                &self,
815                _ctx: &Arc<Context>,
816                _cfg: Self::Config,
817            ) -> Result<Arc<Self::Provides>, CordisError> {
818                Ok(Arc::new(FooService(3)))
819            }
820        }
821        let err = registry
822            .register(&tenant_a, FooPlugin3, ())
823            .expect_err("duplicate in same isolate should fail");
824        assert!(err.to_string().contains("duplicate provider for"));
825        // Different isolate label is allowed.
826        let tenant_b = root.isolate::<FooService>("tenant:other");
827        let fid_b = registry
828            .register(&tenant_b, FooPlugin3, ())
829            .expect("different isolate label should be allowed");
830        assert!(registry.get_fiber(fid_b).is_some());
831    }
832
833    /// Shared helper that verifies a plugin registers, its fiber is tracked,
834    /// and the provided service is retrievable via `ctx.get`. Extracted to
835    /// eliminate near-duplicate test bodies (88% alike) reported by rust-doctor.
836    fn assert_plugin_retrievable<T, P>(
837        registry: &RegistryService,
838        ctx: &Arc<Context>,
839        plugin: P,
840        expect: impl FnOnce(&T),
841    ) where
842        T: Service + std::fmt::Debug,
843        P: Plugin<Provides = T, Config = ()>,
844    {
845        let fid = registry
846            .plugin(ctx, plugin, ())
847            .expect("plugin alias should work");
848        assert!(registry.get_fiber(fid).is_some());
849        let svc = ctx
850            .get::<T>()
851            .expect("service should be retrievable via ctx.get");
852        expect(&svc);
853    }
854
855    #[test]
856    fn successful_provide_retrievable_via_ctx_get() {
857        let ctx = Context::new_root();
858        let registry = RegistryService::new();
859        assert_plugin_retrievable(&registry, &ctx, BarPlugin, |svc: &BarService| {
860            assert_eq!(svc.0, 99);
861        });
862        // Registry length reflects one fiber.
863        assert_eq!(registry.len(), 1);
864    }
865
866    #[tokio::test]
867    async fn registration_fiber_disposal_removes_only_its_service() {
868        let ctx = Context::new_root();
869        let registry = RegistryService::new();
870        let foo = registry
871            .register(&ctx, FooPlugin, ())
872            .expect("foo registration");
873        let bar = registry
874            .register(&ctx, BarPlugin, ())
875            .expect("bar registration");
876        assert!(ctx.get::<FooService>().is_some());
877        assert!(ctx.get::<BarService>().is_some());
878        let _ = registry.get_fiber(foo).unwrap().dispose().await;
879        assert!(ctx.get::<FooService>().is_none());
880        assert_eq!(ctx.get::<BarService>().unwrap().0, 99);
881        assert!(matches!(
882            registry.get_fiber(bar).unwrap().state(),
883            FiberState::Active { .. }
884        ));
885        registry.get_fiber(foo).unwrap().refresh(&ctx).await;
886        assert!(ctx.get::<FooService>().is_none());
887    }
888
889    struct Dependency;
890    impl Service for Dependency {}
891
892    struct CountingPlugin {
893        calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
894    }
895
896    impl Plugin for CountingPlugin {
897        type Config = ();
898        type Provides = FooService;
899
900        fn apply(
901            &self,
902            _ctx: &Arc<Context>,
903            _cfg: Self::Config,
904        ) -> Result<Arc<Self::Provides>, CordisError> {
905            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
906            Ok(Arc::new(FooService(7)))
907        }
908    }
909
910    #[tokio::test]
911    async fn refresh_reruns_provider_after_dependency_version_change() {
912        let ctx = Context::new_root();
913        let registry = RegistryService::new();
914        let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
915        let fid = registry
916            .register(
917                &ctx,
918                CountingPlugin {
919                    calls: calls.clone(),
920                },
921                (),
922            )
923            .expect("registration");
924        let fiber = registry.get_fiber(fid).unwrap();
925        fiber.declare_inject::<Dependency>();
926        ctx.provide(Dependency);
927        fiber.refresh(&ctx).await;
928        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
929        assert!(matches!(fiber.state(), FiberState::Active { .. }));
930    }
931
932    #[tokio::test]
933    async fn missing_dependency_deactivates_then_provide_reactivates() {
934        let ctx = Context::new_root();
935        let registry = RegistryService::new();
936        let fid = registry
937            .register(&ctx, FooPlugin, ())
938            .expect("registration");
939        let fiber = registry.get_fiber(fid).unwrap();
940        fiber.declare_inject::<Dependency>();
941        fiber.refresh(&ctx).await;
942        assert!(matches!(fiber.state(), FiberState::Inactive { .. }));
943        assert!(ctx.get::<FooService>().is_none());
944        ctx.provide(Dependency);
945        fiber.refresh(&ctx).await;
946        assert!(matches!(fiber.state(), FiberState::Active { .. }));
947        assert!(ctx.get::<FooService>().is_some());
948    }
949
950    #[test]
951    fn isolate_lookup_does_not_cross_realms() {
952        let root = Context::new_root();
953        root.provide(FooService(1));
954        let tenant = root.isolate::<FooService>("tenant:a");
955        assert!(tenant.get::<FooService>().is_none());
956        tenant.provide(FooService(2));
957        assert_eq!(tenant.get::<FooService>().unwrap().0, 2);
958        let other = root.isolate::<FooService>("tenant:b");
959        assert!(other.get::<FooService>().is_none());
960    }
961
962    // --- guarded withdrawal (paper §4.3.1 relied_n) ---
963
964    struct DependentPlugin;
965    impl Plugin for DependentPlugin {
966        type Config = ();
967        type Provides = BarService;
968        fn apply(
969            &self,
970            _ctx: &Arc<Context>,
971            _cfg: Self::Config,
972        ) -> Result<Arc<Self::Provides>, CordisError> {
973            Ok(Arc::new(BarService(5)))
974        }
975    }
976
977    /// Register a consumer of `FooService` through the registry so it is
978    /// Active and declares its inject against a recorded registration context.
979    fn register_foo_consumer(ctx: &Arc<Context>, registry: &RegistryService) -> FiberId {
980        let fid = registry
981            .register(&ctx.clone(), DependentPlugin, ())
982            .expect("dependent registration");
983        registry
984            .get_fiber(fid)
985            .unwrap()
986            .declare_inject::<FooService>();
987        fid
988    }
989
990    #[tokio::test]
991    async fn guarded_withdrawal_blocks_removal_with_active_consumer() {
992        use crate::Context;
993        let ctx = Context::new_root();
994        let registry = RegistryService::new();
995        ctx.provide(registry);
996        let registry = ctx.get::<RegistryService>().unwrap();
997
998        let provider_fid = registry.register(&ctx, FooPlugin, ()).expect("provider");
999        let consumer_fid = register_foo_consumer(&ctx, &registry);
1000        assert!(matches!(
1001            registry.get_fiber(consumer_fid).unwrap().state(),
1002            FiberState::Active { .. }
1003        ));
1004
1005        let key = (TypeId::of::<FooService>(), None);
1006        assert_eq!(registry.reliance_count(&key), 1);
1007
1008        // Guarded withdrawal: removal must fail while the consumer is Active.
1009        let err = ctx.remove::<FooService>().expect_err("guard must block");
1010        assert!(
1011            err.to_string().contains("guarded withdrawal"),
1012            "error should mention guarded withdrawal, got {err}"
1013        );
1014        assert!(ctx.get::<FooService>().is_some(), "service stays provided");
1015        assert!(matches!(
1016            registry.get_fiber(provider_fid).unwrap().state(),
1017            FiberState::Active { .. }
1018        ));
1019    }
1020
1021    #[tokio::test]
1022    async fn guarded_withdrawal_allows_after_consumer_gone() {
1023        use crate::Context;
1024        let ctx = Context::new_root();
1025        let registry = RegistryService::new();
1026        ctx.provide(registry);
1027        let registry = ctx.get::<RegistryService>().unwrap();
1028
1029        registry.register(&ctx, FooPlugin, ()).expect("provider");
1030        let consumer_fid = register_foo_consumer(&ctx, &registry);
1031
1032        // Dispose the consumer: its effects are undone and it is no longer
1033        // Active, so reliance drops to zero and removal succeeds.
1034        let _ = registry.get_fiber(consumer_fid).unwrap().dispose().await;
1035        let key = (TypeId::of::<FooService>(), None);
1036        assert_eq!(registry.reliance_count(&key), 0);
1037
1038        let removed = ctx.remove::<FooService>().expect("removal now allowed");
1039        assert_eq!(removed.unwrap().0, 1);
1040        assert!(ctx.get::<FooService>().is_none());
1041    }
1042
1043    #[tokio::test]
1044    async fn internal_undo_bypasses_guard() {
1045        use crate::Context;
1046        let ctx = Context::new_root();
1047        let registry = RegistryService::new();
1048        ctx.provide(registry);
1049        let registry = ctx.get::<RegistryService>().unwrap();
1050
1051        registry.register(&ctx, FooPlugin, ()).expect("provider");
1052        register_foo_consumer(&ctx, &registry);
1053        let key = (TypeId::of::<FooService>(), None);
1054        assert_eq!(registry.reliance_count(&key), 1);
1055
1056        // The forced path (used by fiber undo stacks / internal rollback)
1057        // removes even with an active consumer.
1058        let removed = ctx.remove_forced::<FooService>().expect("forced removal");
1059        assert!(removed.is_some());
1060        assert!(ctx.get::<FooService>().is_none());
1061    }
1062
1063    #[tokio::test]
1064    async fn remove_without_registry_or_consumers_still_works() {
1065        use crate::Context;
1066        // No RegistryService on ctx: guard is inert, behavior as before.
1067        let ctx = Context::new_root();
1068        ctx.provide(FooService(2));
1069        let removed = ctx.remove::<FooService>().expect("unguarded removal");
1070        assert!(removed.is_some());
1071    }
1072
1073    #[test]
1074    fn plugin_alias_behaves_like_register() {
1075        // Intentionally spelled out without the shared helper to keep the two
1076        // alias tests at distinct AST shapes; the helper path is exercised by
1077        // `successful_provide_retrievable_via_ctx_get` above.
1078        let ctx = Context::new_root();
1079        let registry = RegistryService::new();
1080        let fid = registry
1081            .plugin(&ctx, FooPlugin, ())
1082            .expect("plugin alias ok");
1083        assert!(registry.get_fiber(fid).is_some());
1084        let svc = ctx
1085            .get::<FooService>()
1086            .expect("FooService should be present");
1087        assert_eq!(svc.0, 1);
1088        // Extra distinct check: FooService isolate should not have created BarService.
1089        assert!(ctx.get::<BarService>().is_none());
1090        assert_eq!(registry.len(), 1);
1091    }
1092
1093    /// Dead-fiber pruning: a disposed fiber is dropped by `prune_disposed`
1094    /// (and by opportunistic sweeps on `len`), while a Failed fiber survives
1095    /// — inspectable by design.
1096    #[tokio::test]
1097    async fn prune_disposed_drops_disposed_but_keeps_failed() {
1098        let ctx = Context::new_root();
1099        let registry = RegistryService::new();
1100
1101        let failed_fid = registry
1102            .register(&ctx, FailingPlugin, ())
1103            .expect_err("failing registration is rejected but tracked");
1104        let failed_fid = match failed_fid {
1105            CordisError::Configuration(_) => 1, // first allocation on a fresh registry
1106            other => panic!("unexpected error shape: {other:?}"),
1107        };
1108        let live_fid = registry
1109            .register(&ctx, FooPlugin, ())
1110            .expect("live registration");
1111        assert!(matches!(
1112            registry.get_fiber(failed_fid).unwrap().state(),
1113            FiberState::Failed { .. }
1114        ));
1115
1116        // Force dispose the live fiber: its undos retract the service and the
1117        // `disposed` flag flips on.
1118        let _ = registry.get_fiber(live_fid).unwrap().dispose().await;
1119        assert!(registry.get_fiber(live_fid).unwrap().is_disposed());
1120
1121        let pruned = registry.prune_disposed();
1122        assert_eq!(pruned, 1, "exactly the disposed fiber is pruned");
1123        assert!(
1124            registry.get_fiber(live_fid).is_none(),
1125            "disposed fiber must be gone after prune"
1126        );
1127        // Failed fiber SURVIVES the prune — inspectable-by-design.
1128        assert!(matches!(
1129            registry.get_fiber(failed_fid).unwrap().state(),
1130            FiberState::Failed { .. }
1131        ));
1132        // The live Active fiber is untouched bookkeeping too.
1133        let bar_fid = registry
1134            .register(&ctx, BarPlugin, ())
1135            .expect("bar registration");
1136        assert!(registry.get_fiber(bar_fid).is_some());
1137
1138        // len() opportunistically re-prunes: dispose bar and a plain size
1139        // probe must clear it without an explicit prune call.
1140        let _ = registry.get_fiber(bar_fid).unwrap().dispose().await;
1141        assert_eq!(registry.len(), 1, "only the Failed fiber remains");
1142        assert!(registry.get_fiber(bar_fid).is_none());
1143    }
1144
1145    /// Reactive Pending fibers survive `prune_disposed`: they are NOT
1146    /// disposed (no dispose ran), so the prune predicate keeps them tracked,
1147    /// and their provider slot stays reserved while they wait.
1148    #[tokio::test]
1149    async fn pending_fiber_survives_prune_disposed() {
1150        let ctx = Context::new_root();
1151        ctx.provide(ReflectService::new());
1152        let reflect = ctx.get::<ReflectService>().unwrap();
1153        reflect.set_context(&ctx);
1154        let registry = RegistryService::new();
1155        ctx.provide(registry);
1156        let registry = ctx.get::<RegistryService>().unwrap();
1157
1158        // Provider + consumer, consumer declares its inject reactively.
1159        let provider_fid = registry.register(&ctx, FooPlugin, ()).expect("provider");
1160        let fid = registry
1161            .register(&ctx, DependentPlugin, ())
1162            .expect("consumer registration");
1163        let fiber = registry.get_fiber(fid).unwrap();
1164        fiber.declare_inject::<FooService>();
1165        fiber.refresh(&ctx).await;
1166        assert!(matches!(fiber.state(), FiberState::Active { .. }));
1167
1168        // Reactive dependency loss: the provider registration is retired
1169        // (disposed), which retracts the service and reactively notifies the
1170        // consumer.
1171        let _ = registry.get_fiber(provider_fid).unwrap().dispose().await;
1172        reflect.notify_with_ctx(TypeId::of::<FooService>(), &ctx).await;
1173        assert!(
1174            matches!(fiber.state(), FiberState::Pending),
1175            "consumer must rest Pending after dep loss, got {:?}",
1176            fiber.state()
1177        );
1178
1179        // Prune drops the DISPOSED provider but keeps the Pending consumer:
1180        // the predicate is is_disposed(), and Pending fibers are not.
1181        let pruned = registry.prune_disposed();
1182        assert_eq!(pruned, 1, "exactly the disposed provider is pruned");
1183        assert!(
1184            matches!(fiber.state(), FiberState::Pending),
1185            "Pending fiber must survive prune"
1186        );
1187        assert!(registry.get_fiber(fid).is_some(), "still tracked");
1188
1189        // Provider returns: the surviving Pending fiber reactivates.
1190        registry.register(&ctx, FooPlugin, ()).expect("provider back");
1191        reflect.notify_with_ctx(TypeId::of::<FooService>(), &ctx).await;
1192        assert!(
1193            matches!(fiber.state(), FiberState::Active { .. }),
1194            "reactivation after prune-pass, got {:?}",
1195            fiber.state()
1196        );
1197        assert_eq!(
1198            registry.len(),
1199            2,
1200            "both live fibers remain tracked through the cycle"
1201        );
1202    }
1203
1204    /// Pruning a disposed provider clears its `provided` slot so the same key
1205    /// can register again without a stale duplicate-provider conflict.
1206    #[tokio::test]
1207    async fn prune_clears_provided_slot_for_fresh_registration() {
1208        let ctx = Context::new_root();
1209        let registry = RegistryService::new();
1210        let fid = registry
1211            .register(&ctx, FooPlugin, ())
1212            .expect("first registration");
1213        let _ = registry.get_fiber(fid).unwrap().dispose().await;
1214
1215        // Without pruning, the stale entry does not block (the conflict check
1216        // already ignores non-active providers)...
1217        let fid2 = registry
1218            .register(&ctx, FooPlugin2, ())
1219            .expect("re-registration works even pre-prune");
1220        // ...but prune still removes both dead fibers from tracking: dispose
1221        // the stale original again (idempotent) and the fresh one too.
1222        let _ = registry.get_fiber(fid).unwrap().dispose().await;
1223        let _ = registry.get_fiber(fid2).unwrap().dispose().await;
1224        assert_eq!(registry.prune_disposed(), 2);
1225        assert!(registry.get_fiber(fid).is_none());
1226        assert!(registry.get_fiber(fid2).is_none());
1227        assert_eq!(registry.len(), 0);
1228    }
1229
1230    // ------------------------------------------------------------------
1231    // Availability predicates (Service::check) at registration time
1232    // ------------------------------------------------------------------
1233
1234    #[derive(Debug)]
1235    struct GatedService(bool);
1236    impl Service for GatedService {
1237        fn check(&self) -> bool {
1238            self.0
1239        }
1240    }
1241
1242    struct GatedPlugin {
1243        ready: bool,
1244    }
1245
1246    impl Plugin for GatedPlugin {
1247        type Config = ();
1248        type Provides = GatedService;
1249
1250        fn apply(
1251            &self,
1252            _ctx: &Arc<Context>,
1253            _config: Self::Config,
1254        ) -> Result<Arc<Self::Provides>, CordisError> {
1255            Ok(Arc::new(GatedService(self.ready)))
1256        }
1257    }
1258
1259    /// A plugin-produced service whose `Service::check` verdict is `false`
1260    /// rests its registration fiber as an inspectable `Failed` naming the
1261    /// rejection — never silently `Active`, never missing from tracking.
1262    /// Registration stays non-throwing (register-before-ready is a supported
1263    /// transient): a later ready provider of the same key converges the
1264    /// fiber back to Active on refresh.
1265    #[tokio::test]
1266    async fn availability_predicate_rejection_registers_failed() {
1267        let ctx = Context::new_root();
1268        ctx.provide(ReflectService::new());
1269        if let Some(reflect) = ctx.get::<crate::ReflectService>() {
1270            reflect.set_context(&ctx);
1271        }
1272        let registry = RegistryService::new();
1273
1274        let fid = registry
1275            .register(&ctx, GatedPlugin { ready: false }, ())
1276            .expect("predicate rejection must NOT fail registration");
1277        let fiber = registry.get_fiber(fid).expect("tracked");
1278        match fiber.state() {
1279            crate::FiberState::Failed { error } => {
1280                assert!(error
1281                    .as_deref()
1282                    .unwrap_or("")
1283                    .contains("availability predicate rejected service"));
1284            }
1285            other => panic!("expected Failed state, got {other:?}"),
1286        }
1287        // The unready value was never exposed to consumers.
1288        assert!(ctx.get::<GatedService>().is_none());
1289        // Convergence after a ready re-provision is covered by
1290        // predicate_passing_reregistration_activates_dependents below.
1291    }
1292
1293    /// The reactive leg: after a rejected registration, registering a passing
1294    /// implementation of the same key activates dependents — mirroring the
1295    /// version_conformance shapes.
1296    #[tokio::test]
1297    async fn predicate_passing_reregistration_activates_dependents() {
1298        let ctx = Context::new_root();
1299        ctx.provide(ReflectService::new());
1300        if let Some(reflect) = ctx.get::<crate::ReflectService>() {
1301            reflect.set_context(&ctx);
1302        }
1303        let registry = RegistryService::new();
1304
1305        // Rejected first: registers fine but rests Failed with the key
1306        // unserved.
1307        let bad_fid = registry
1308            .register(&ctx, GatedPlugin { ready: false }, ())
1309            .expect("rejection is non-throwing");
1310        assert!(matches!(
1311            registry.get_fiber(bad_fid).unwrap().state(),
1312            crate::FiberState::Failed { .. }
1313        ));
1314
1315        // Dependent declared against the gated TypeId while it is absent.
1316        struct GatedConsumer;
1317        impl Plugin for GatedConsumer {
1318            type Config = ();
1319            type Provides = DerivedProbe;
1320
1321            fn apply(
1322                &self,
1323                _ctx: &Arc<Context>,
1324                _config: Self::Config,
1325            ) -> Result<Arc<Self::Provides>, CordisError> {
1326                Ok(Arc::new(DerivedProbe))
1327            }
1328        }
1329
1330        #[derive(Debug)]
1331        struct DerivedProbe;
1332        impl Service for DerivedProbe {}
1333
1334        let dep_fid = registry
1335            .register(&ctx, GatedConsumer, ())
1336            .expect("consumer registers even without its dependency");
1337        let dependent = registry.get_fiber(dep_fid).unwrap();
1338        dependent.declare_inject::<GatedService>();
1339        dependent.refresh(&ctx).await;
1340        assert!(
1341            matches!(dependent.state(), crate::FiberState::Inactive { .. }),
1342            "dependent must stay Inactive while the provider is rejected, got {:?}",
1343            dependent.state()
1344        );
1345
1346        // A passing implementation of the same key now activates the
1347        // dependent through the reactive notify path.
1348        registry
1349            .register(&ctx, GatedPlugin { ready: true }, ())
1350            .expect("passing provider must register");
1351        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1352        dependent.refresh(&ctx).await;
1353        match dependent.state() {
1354            crate::FiberState::Active { .. } => {}
1355            other => {
1356                panic!("dependent should activate after a passing re-registration, got {other:?}")
1357            }
1358        }
1359        assert!(ctx.get::<GatedService>().is_some());
1360    }
1361
1362    // ------------------------------------------------------------------
1363    // C2 readiness gates (ready_when): quiet Pending waiting, AND-composition,
1364    // external re-kick. Complement of the availability predicates above:
1365    // `Service::check` failures are LOUD (`Failed{error}`), a closed
1366    // readiness gate is QUIET (`Pending`, no error, factory never re-runs).
1367    // ------------------------------------------------------------------
1368
1369    #[derive(Debug)]
1370    struct ReadinessProbe;
1371    impl Service for ReadinessProbe {}
1372
1373    struct ReadinessPlugin;
1374    impl Plugin for ReadinessPlugin {
1375        type Config = ();
1376        type Provides = ReadinessProbe;
1377
1378        fn apply(
1379            &self,
1380            _ctx: &Arc<Context>,
1381            _config: Self::Config,
1382        ) -> Result<Arc<Self::Provides>, CordisError> {
1383            Ok(Arc::new(ReadinessProbe))
1384        }
1385    }
1386
1387    /// A registration whose `ready_when` gate starts closed rests its fiber
1388    /// as inspectable `Pending` (NOT Failed), keeps the produced service out
1389    /// of consumer reach, and flips to Active once the gate opens — without
1390    /// ever re-running the plugin factory.
1391    #[tokio::test]
1392    async fn ready_when_holds_pending_until_true_then_activates() {
1393        let ctx = Context::new_root();
1394        let registry = RegistryService::new();
1395
1396        let open = Arc::new(std::sync::atomic::AtomicBool::new(false));
1397        let gate_flag = open.clone();
1398        let fid = registry
1399            .register_with_readiness(
1400                &ctx,
1401                ReadinessPlugin,
1402                (),
1403                ReadinessBarrier::new(move |_ctx| {
1404                    gate_flag.load(std::sync::atomic::Ordering::Acquire)
1405                }),
1406            )
1407            .expect("gated registration is non-throwing");
1408
1409        // The freshly-installed gate decides the resting state on the
1410        // registration's own re-entry pass.
1411        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1412        let fiber = registry.get_fiber(fid).expect("tracked");
1413        assert!(
1414            matches!(fiber.state(), FiberState::Pending),
1415            "closed gate must rest Pending, got {:?}",
1416            fiber.state()
1417        );
1418        assert!(
1419            ctx.get::<ReadinessProbe>().is_none(),
1420            "strict get refuses values owned by non-Active fibers"
1421        );
1422
1423        // Open the gate and re-kick through the normal lifecycle entry point.
1424        open.store(true, std::sync::atomic::Ordering::Release);
1425        fiber.refresh(&ctx).await;
1426        match fiber.state() {
1427            FiberState::Active { .. } => {}
1428            other => panic!("open gate must activate, got {other:?}"),
1429        }
1430        assert!(ctx.get::<ReadinessProbe>().is_some(), "now served");
1431    }
1432
1433    /// AND-composition via [`with_readiness`]: the combined barrier is ready
1434    /// only when EVERY operand reports ready; opening one half while the
1435    /// other stays closed keeps the fiber waiting.
1436    #[tokio::test]
1437    async fn readiness_composes_and_semantics() {
1438        let ctx = Context::new_root();
1439        let registry = RegistryService::new();
1440
1441        let a_open = Arc::new(std::sync::atomic::AtomicBool::new(false));
1442        let b_open = Arc::new(std::sync::atomic::AtomicBool::new(false));
1443        let combined = with_readiness([
1444            {
1445                let flag = a_open.clone();
1446                ReadinessBarrier::new(move |_ctx| {
1447                    flag.load(std::sync::atomic::Ordering::Acquire)
1448                })
1449            },
1450            {
1451                let flag = b_open.clone();
1452                ReadinessBarrier::new(move |_ctx| {
1453                    flag.load(std::sync::atomic::Ordering::Acquire)
1454                })
1455            },
1456        ]);
1457        // Direct evaluation first: both closed / one open / both open.
1458        assert!(!combined.is_ready(&ctx), "both closed must not be ready");
1459        a_open.store(true, std::sync::atomic::Ordering::Release);
1460        assert!(
1461            !combined.is_ready(&ctx),
1462            "AND semantics: one open half is not enough"
1463        );
1464        b_open.store(true, std::sync::atomic::Ordering::Release);
1465        assert!(combined.is_ready(&ctx), "both open must be ready");
1466        // The empty composition is vacuously ready.
1467        assert!(with_readiness([]).is_ready(&ctx));
1468
1469        // Register while only HALF the composed gate is open: the fiber must
1470        // keep waiting Pending (AND semantics at rest), then activate once
1471        // the second half opens.
1472        b_open.store(false, std::sync::atomic::Ordering::Release);
1473        a_open.store(true, std::sync::atomic::Ordering::Release);
1474
1475        let fid = registry
1476            .register_with_readiness(&ctx, ReadinessPlugin, (), combined)
1477            .expect("composed-gate registration");
1478        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1479        let fiber = registry.get_fiber(fid).expect("tracked");
1480        assert!(
1481            matches!(fiber.state(), FiberState::Pending),
1482            "one-open-half must still wait Pending, got {:?}",
1483            fiber.state()
1484        );
1485
1486        // Second half opens: the composed gate goes ready and the fiber
1487        // activates on its next lifecycle pass.
1488        b_open.store(true, std::sync::atomic::Ordering::Release);
1489        fiber.refresh(&ctx).await;
1490        match fiber.state() {
1491            FiberState::Active { .. } => {}
1492            other => panic!("fully-open AND gate must activate, got {other:?}"),
1493        }
1494    }
1495
1496    /// Re-kick wiring: an EXTERNAL settle (another managed fiber providing /
1497    /// withdrawing) fans out through the round-5 observer notify path and
1498    /// re-evaluates the gated fiber — it activates without any direct call
1499    /// to refresh on the gated fiber itself.
1500    #[tokio::test]
1501    async fn external_rekick_reactivates_waiting_fiber() {
1502        let ctx = Context::new_root();
1503        ctx.provide(ReflectService::new());
1504        if let Some(reflect) = ctx.get::<crate::ReflectService>() {
1505            reflect.set_context(&ctx);
1506        }
1507        let registry = RegistryService::new();
1508        ctx.provide(registry);
1509        let registry = ctx.get::<RegistryService>().unwrap();
1510
1511        // Gate observes a plain context fact: whether Dependency is provided.
1512        // `watching` declares the settle source so external provides /
1513        // withdrawals of Dependency re-kick this fiber through Reflect.
1514        let fid = registry
1515            .register_with_readiness(
1516                &ctx,
1517                ReadinessPlugin,
1518                (),
1519                ReadinessBarrier::new(|ctx: &Arc<Context>| ctx.get::<Dependency>().is_some())
1520                    .watching([TypeId::of::<Dependency>()]),
1521            )
1522            .expect("fact-gated registration");
1523        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1524        let fiber = registry.get_fiber(fid).expect("tracked");
1525        assert!(matches!(fiber.state(), FiberState::Pending));
1526
1527        // External settle: another fiber provides Dependency. The provide
1528        // notifies the ReflectService fan-out (round-5 observer path), which
1529        // BFS-refreshes dependents — including our gated fiber.
1530        let dep_fid = registry
1531            .register(&ctx, BarPlugin, ())
1532            .expect("dependency provider registers");
1533        assert!(dep_fid > 0);
1534        ctx.provide(Dependency);
1535        if let Some(reflect) = ctx.get::<crate::ReflectService>() {
1536            reflect.notify_with_ctx(TypeId::of::<Dependency>(), &ctx).await;
1537        }
1538        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1539
1540        match fiber.state() {
1541            FiberState::Active { .. } => {}
1542            other => panic!(
1543                "external re-kick must reactivate the waiting fiber, got {other:?}"
1544            ),
1545        }
1546        assert!(ctx.get::<ReadinessProbe>().is_some());
1547
1548        // Withdrawal settles too: the fact flips, the next re-kick rests the
1549        // fiber back to Pending — never Failed — proving bidirectional
1550        // complementarity with loud predicate failures.
1551        let _removed = ctx.remove::<Dependency>();
1552        if let Some(reflect) = ctx.get::<crate::ReflectService>() {
1553            reflect.notify_with_ctx(TypeId::of::<Dependency>(), &ctx).await;
1554        }
1555        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1556        assert!(
1557            matches!(fiber.state(), FiberState::Pending),
1558            "gate closing again must rest Pending quietly, got {:?}",
1559            fiber.state()
1560        );
1561    }
1562}
1563
1564#[cfg(test)]
1565mod config_waterfall_tests {
1566    use super::*;
1567    use crate::events::{EventsService, INTERNAL_CONFIG_EVENT};
1568    use crate::{Context, FiberState};
1569
1570    /// Captures the config its factory actually received.
1571    struct CapturePlugin;
1572    #[derive(Debug)]
1573    struct CapturedConfig(pub serde_json::Value);
1574    impl Service for CapturedConfig {}
1575    impl Plugin for CapturePlugin {
1576        type Config = serde_json::Value;
1577        type Provides = CapturedConfig;
1578        fn apply(
1579            &self,
1580            _ctx: &Arc<Context>,
1581            cfg: Self::Config,
1582        ) -> Result<Arc<Self::Provides>, CordisError> {
1583            Ok(Arc::new(CapturedConfig(cfg)))
1584        }
1585    }
1586
1587    /// C1 `internal/config` covers the ACTIVATION path: the very first runner
1588    /// pass at registration time must apply the intercepted (effective)
1589    /// config, not the raw one. A multi-thread runtime is required so the
1590    /// synchronous bridge can park the worker.
1591    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1592    async fn config_waterfall_covers_activation_path() {
1593        let ctx = Context::new_root();
1594        let events = Arc::new(EventsService::new());
1595        ctx.provide_arc(events.clone());
1596        let _gate = events.on(INTERNAL_CONFIG_EVENT.into(), |raw| async move {
1597            let mut effective = raw;
1598            if let Some(obj) = effective.as_object_mut() {
1599                obj.insert("rewritten_at_activation".into(), serde_json::json!(true));
1600            }
1601            Ok(effective)
1602        });
1603
1604        let registry = RegistryService::new();
1605        let fid = registry
1606            .register(
1607                &ctx,
1608                CapturePlugin,
1609                serde_json::json!({ "model": "base" }),
1610            )
1611            .expect("registration with an intercept-config listener");
1612        assert!(matches!(
1613            registry.get_fiber(fid).unwrap().state(),
1614            FiberState::Active { .. }
1615        ));
1616        let captured = ctx.get::<CapturedConfig>().expect("provider active");
1617        assert_eq!(captured.0["model"], "base");
1618        assert_eq!(
1619            captured.0["rewritten_at_activation"], true,
1620            "activation pass must consume the EFFECTIVE config"
1621        );
1622    }
1623}