Skip to main content

subc_daemon/
registry.rs

1use std::{
2    collections::HashMap,
3    error::Error,
4    fmt,
5    sync::{Mutex, MutexGuard},
6};
7
8use subc_protocol::manifest::{CapabilityDeclarations, ModuleManifest, ProviderRole};
9use tokio::sync::watch;
10use tracing::info;
11
12/// Per-connection identity assigned by [`crate::Router`] while serving a socket.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub struct ConnectionId(u64);
15
16impl ConnectionId {
17    /// Synthetic connection id used by unit tests. Router-issued connection ids
18    /// start at 1, so this 0 value never collides with a real socket owner.
19    #[cfg(test)]
20    pub const LOCAL: Self = Self(0);
21
22    pub const fn new(raw: u64) -> Self {
23        Self(raw)
24    }
25
26    pub fn get(self) -> u64 {
27        self.0
28    }
29}
30
31/// Lifecycle state for a module's channel allocation.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ChannelState {
34    Active,
35    Closed,
36}
37
38/// Why a module registration stopped belonging to its connection.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub(crate) enum RegistrationEndReason {
41    ConnectionClosed,
42    ExplicitGoodbye,
43    Replaced,
44    SupervisorStop,
45    SupervisorRestart,
46    RegistrationFailed,
47}
48
49impl RegistrationEndReason {
50    fn as_str(self) -> &'static str {
51        match self {
52            Self::ConnectionClosed => "connection_closed",
53            Self::ExplicitGoodbye => "explicit_goodbye",
54            Self::Replaced => "replaced",
55            Self::SupervisorStop => "supervisor_stop",
56            Self::SupervisorRestart => "supervisor_restart",
57            Self::RegistrationFailed => "registration_failed",
58        }
59    }
60}
61
62/// Registry record for one active module registration.
63#[derive(Debug, Clone, PartialEq)]
64pub struct ModuleRegistration {
65    pub manifest: ModuleManifest,
66    pub ready: bool,
67    pub negotiated_ver: u8,
68    pub state: ChannelState,
69    pub connection_id: ConnectionId,
70    pub control_ops: Vec<String>,
71}
72
73/// Which registration a lookup or a lifecycle wait is about.
74///
75/// A module id alone stops naming one process once a blue/green swap runs two
76/// processes under the same id: the incumbent in the active slot, the
77/// replacement in the candidate slot, and, after cutover, the old incumbent
78/// demoted until its connection goes away. A wait keyed on the bare id would
79/// confuse them (a successful swap never empties the id's active slot, and a
80/// candidate's "has it registered yet" would be answered by the incumbent).
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum RegistrationSlot<'a> {
83    /// The routable registration for this module id. This is what every plain
84    /// start, stop and restart path means by "the module is registered".
85    Active(&'a str),
86    /// A swap candidate registered under this module id and not yet promoted.
87    Candidate(&'a str),
88    /// Whatever registration this module connection holds, in any slot.
89    Connection(ConnectionId),
90}
91
92/// The result of promoting a module's candidate registration to active.
93#[derive(Debug, Clone, PartialEq)]
94pub struct RegistryCutover {
95    /// The registration that is now active (the former candidate).
96    pub promoted: ModuleRegistration,
97    /// The former active registration, now demoted. `None` when the incumbent
98    /// had already gone away before the promotion.
99    pub superseded: Option<ModuleRegistration>,
100}
101
102/// Control-plane registry for module manifests and supervision ownership.
103///
104/// Duplicate active `module_id`s are rejected rather than replaced. Rejection is
105/// the safer v1 behavior because replacing a still-connected module could hijack
106/// in-flight routes. Stale registrations are removed by connection cleanup; a
107/// reconnect after the old connection drops can then register the same id again.
108///
109/// A blue/green swap is the one sanctioned way two processes hold the same id.
110/// The replacement registers into a separate candidate slot, which every by-id
111/// lookup ignores, so it stays unroutable until [`Registry::promote_candidate`]
112/// swaps it in. The old incumbent is then kept as "superseded" until its
113/// connection deregisters. Lookups keyed by connection id search all three
114/// slots, because a connection must be able to update and remove its own
115/// registration whichever slot it currently sits in.
116#[derive(Debug, Default)]
117pub struct Registry {
118    inner: Mutex<RegistryInner>,
119}
120
121#[derive(Debug, Default)]
122struct RegistryInner {
123    /// Only subscribed module ids have a sender. Changes coalesce, and writers
124    /// never wait for a supervisor to receive them.
125    module_changes: HashMap<String, watch::Sender<()>>,
126    modules: HashMap<String, ModuleRegistration>,
127    /// Swap candidates by module id: registered, never routable, never listed.
128    candidates: HashMap<String, ModuleRegistration>,
129    /// Former incumbents demoted by a promotion, kept only so their own
130    /// connection can still find and remove them. Never routable, never listed.
131    superseded: Vec<ModuleRegistration>,
132    /// The active connection that displaced each superseded registration.
133    superseded_by: HashMap<ConnectionId, ConnectionId>,
134    generation: u64,
135}
136
137impl Registry {
138    /// Subscribe before reading a registration so a change between the read
139    /// and the supervisor's wait remains pending on this receiver.
140    pub(crate) fn subscribe_module_changes(
141        &self,
142        module_id: &str,
143    ) -> Result<watch::Receiver<()>, RegistryError> {
144        let mut inner = self.lock_inner()?;
145        // Retired supervisors must not leave an ever-growing set of ids.
146        inner
147            .module_changes
148            .retain(|_, sender| sender.receiver_count() > 0);
149        Ok(inner
150            .module_changes
151            .entry(module_id.to_string())
152            .or_insert_with(|| watch::channel(()).0)
153            .subscribe())
154    }
155
156    /// Register a module manifest with the module's effective granted control op set.
157    pub fn register_with_control_ops(
158        &self,
159        manifest: ModuleManifest,
160        negotiated_ver: u8,
161        connection_id: ConnectionId,
162        control_ops: Vec<String>,
163    ) -> Result<ModuleRegistration, RegistryError> {
164        let module_id = manifest.module_id.clone();
165        if let Err(reason) = module_id_path_hazard(&module_id) {
166            return Err(RegistryError::PathHazardModuleId { module_id, reason });
167        }
168        let mut inner = self.lock_inner()?;
169        if inner.modules.contains_key(&module_id) {
170            return Err(RegistryError::DuplicateModuleId { module_id });
171        }
172
173        let ready = manifest.ready.unwrap_or(true);
174        let registration = ModuleRegistration {
175            manifest,
176            ready,
177            negotiated_ver,
178            state: ChannelState::Active,
179            connection_id,
180            control_ops,
181        };
182
183        inner.modules.insert(module_id, registration.clone());
184        inner.bump_generation();
185        inner.notify_module_changed(&registration.manifest.module_id);
186        Ok(registration)
187    }
188
189    /// Register a swap candidate for `manifest.module_id` into the candidate slot.
190    ///
191    /// The candidate is invisible to [`Self::get_module`], [`Self::list_modules`]
192    /// and every other by-id lookup until [`Self::promote_candidate`]. An active
193    /// registration for the id is not required, because the incumbent may die
194    /// while the swap is open; deciding whether a candidate may register at all
195    /// belongs to the caller that admits it. A second candidate for the same id
196    /// is refused.
197    pub fn register_candidate_with_control_ops(
198        &self,
199        manifest: ModuleManifest,
200        negotiated_ver: u8,
201        connection_id: ConnectionId,
202        control_ops: Vec<String>,
203    ) -> Result<ModuleRegistration, RegistryError> {
204        let module_id = manifest.module_id.clone();
205        if let Err(reason) = module_id_path_hazard(&module_id) {
206            return Err(RegistryError::PathHazardModuleId { module_id, reason });
207        }
208        let mut inner = self.lock_inner()?;
209        if inner.candidates.contains_key(&module_id) {
210            return Err(RegistryError::DuplicateModuleId { module_id });
211        }
212        let ready = manifest.ready.unwrap_or(true);
213        let registration = ModuleRegistration {
214            manifest,
215            ready,
216            negotiated_ver,
217            state: ChannelState::Active,
218            connection_id,
219            control_ops,
220        };
221        inner.candidates.insert(module_id, registration.clone());
222        inner.notify_module_changed(&registration.manifest.module_id);
223        Ok(registration)
224    }
225
226    /// Move the candidate for `module_id` into the active slot and demote the
227    /// previous active registration, in one registry critical section.
228    ///
229    /// Returns `Ok(None)` when there is no candidate to promote. Bumps the
230    /// catalog generation, because the listed registration for the id changed.
231    pub fn promote_candidate(
232        &self,
233        module_id: &str,
234    ) -> Result<Option<RegistryCutover>, RegistryError> {
235        let mut inner = self.lock_inner()?;
236        let Some(promoted) = inner.candidates.remove(module_id) else {
237            return Ok(None);
238        };
239        let superseded = inner
240            .modules
241            .insert(module_id.to_string(), promoted.clone());
242        if let Some(superseded) = superseded.clone() {
243            let replaced_connection_id = superseded.connection_id.get();
244            inner
245                .superseded_by
246                .insert(superseded.connection_id, promoted.connection_id);
247            inner.superseded.push(superseded);
248            info!(
249                target: "subc.registry",
250                module_id = %module_id,
251                connection_id = promoted.connection_id.get(),
252                replaced = true,
253                replaced_connection_id,
254                "module registration promoted"
255            );
256        }
257        inner.bump_generation();
258        inner.notify_module_changed(module_id);
259        Ok(Some(RegistryCutover {
260            promoted,
261            superseded,
262        }))
263    }
264
265    /// The ACTIVE registration for `module_id`. Candidates and superseded
266    /// incumbents are never returned: this is the lookup routing decisions use.
267    pub fn get_module(&self, module_id: &str) -> Result<Option<ModuleRegistration>, RegistryError> {
268        Ok(self.lock_inner()?.modules.get(module_id).cloned())
269    }
270
271    /// The swap candidate registered for `module_id`, if any.
272    pub fn get_candidate(
273        &self,
274        module_id: &str,
275    ) -> Result<Option<ModuleRegistration>, RegistryError> {
276        Ok(self.lock_inner()?.candidates.get(module_id).cloned())
277    }
278
279    /// The registration held in one specific slot. See [`RegistrationSlot`].
280    pub fn registration(
281        &self,
282        slot: RegistrationSlot<'_>,
283    ) -> Result<Option<ModuleRegistration>, RegistryError> {
284        let inner = self.lock_inner()?;
285        Ok(match slot {
286            RegistrationSlot::Active(module_id) => inner.modules.get(module_id).cloned(),
287            RegistrationSlot::Candidate(module_id) => inner.candidates.get(module_id).cloned(),
288            RegistrationSlot::Connection(connection_id) => inner
289                .find_by_connection(connection_id)
290                .map(|(_, registration)| registration.clone()),
291        })
292    }
293
294    pub fn active_registration_count(&self) -> Result<usize, RegistryError> {
295        Ok(self.lock_inner()?.modules.len())
296    }
297
298    pub fn list_modules(&self) -> Result<(u64, Vec<ModuleRegistration>), RegistryError> {
299        let inner = self.lock_inner()?;
300        let mut modules = inner.modules.values().cloned().collect::<Vec<_>>();
301        modules.sort_by(|left, right| left.manifest.module_id.cmp(&right.manifest.module_id));
302        Ok((inner.generation, modules))
303    }
304
305    pub fn generation(&self) -> Result<u64, RegistryError> {
306        Ok(self.lock_inner()?.generation)
307    }
308
309    #[cfg(test)]
310    pub(crate) fn set_module_state_for_test(
311        &self,
312        module_id: &str,
313        state: ChannelState,
314    ) -> Result<bool, RegistryError> {
315        let mut inner = self.lock_inner()?;
316        let Some(registration) = inner.modules.get_mut(module_id) else {
317            return Ok(false);
318        };
319        registration.state = state;
320        inner.notify_module_changed(module_id);
321        Ok(true)
322    }
323
324    /// The registration owned by `connection_id`, searching the active,
325    /// candidate and superseded slots in that order.
326    pub fn get_module_by_connection(
327        &self,
328        connection_id: ConnectionId,
329    ) -> Result<Option<ModuleRegistration>, RegistryError> {
330        Ok(self
331            .lock_inner()?
332            .find_by_connection(connection_id)
333            .map(|(_, registration)| registration.clone()))
334    }
335
336    /// Replace the provider role list and, when supplied, the attested capability
337    /// declaration for the module owned by `connection_id`.
338    ///
339    /// Searches every slot: a swap candidate declares itself ready through this
340    /// call, and if only the active slot were searched its update would find
341    /// nothing and the candidate would never become ready. Only a change to the
342    /// active slot bumps the catalog generation, because only the active slot is
343    /// listed.
344    pub fn replace_catalog_for_connection(
345        &self,
346        connection_id: ConnectionId,
347        provides: Vec<ProviderRole>,
348        capabilities: Option<CapabilityDeclarations>,
349        ready: Option<bool>,
350    ) -> Result<Option<ModuleRegistration>, RegistryError> {
351        let mut inner = self.lock_inner()?;
352        let Some((slot, _)) = inner.find_by_connection(connection_id) else {
353            return Ok(None);
354        };
355        let registration = inner
356            .registration_mut(slot, connection_id)
357            .expect("registration discovered under the same registry lock must still exist");
358        registration.manifest.provides = provides;
359        if let Some(capabilities) = capabilities {
360            registration.manifest.capabilities = Some(capabilities);
361        }
362        if let Some(ready) = ready {
363            registration.ready = ready;
364            registration.manifest.ready = Some(ready);
365        }
366        let updated = registration.clone();
367        if matches!(slot, SlotKind::Active) {
368            inner.bump_generation();
369        }
370        inner.notify_module_changed(&updated.manifest.module_id);
371        Ok(Some(updated))
372    }
373
374    /// Deregister every module owned by a dropped connection, in any slot.
375    pub fn deregister_connection(
376        &self,
377        connection_id: ConnectionId,
378    ) -> Result<Vec<ModuleRegistration>, RegistryError> {
379        self.deregister_connection_with_reason(
380            connection_id,
381            RegistrationEndReason::ConnectionClosed,
382        )
383    }
384
385    pub(crate) fn deregister_connection_with_reason(
386        &self,
387        connection_id: ConnectionId,
388        reason: RegistrationEndReason,
389    ) -> Result<Vec<ModuleRegistration>, RegistryError> {
390        let mut inner = self.lock_inner()?;
391        let module_ids: Vec<String> = inner
392            .modules
393            .iter()
394            .filter(|(_, registration)| registration.connection_id == connection_id)
395            .map(|(module_id, _)| module_id.clone())
396            .collect();
397
398        let mut closed: Vec<ModuleRegistration> = module_ids
399            .into_iter()
400            .filter_map(|module_id| inner.close_module(&module_id))
401            .collect();
402
403        let candidate_ids: Vec<String> = inner
404            .candidates
405            .iter()
406            .filter(|(_, registration)| registration.connection_id == connection_id)
407            .map(|(module_id, _)| module_id.clone())
408            .collect();
409        for module_id in candidate_ids {
410            if let Some(mut registration) = inner.candidates.remove(&module_id) {
411                registration.state = ChannelState::Closed;
412                closed.push(registration);
413            }
414        }
415
416        let (removed, kept): (Vec<_>, Vec<_>) = std::mem::take(&mut inner.superseded)
417            .into_iter()
418            .partition(|registration| registration.connection_id == connection_id);
419        let superseded_connections: std::collections::HashSet<_> = removed
420            .iter()
421            .map(|registration| registration.connection_id)
422            .collect();
423        inner.superseded = kept;
424        closed.extend(removed.into_iter().map(|mut registration| {
425            registration.state = ChannelState::Closed;
426            registration
427        }));
428        for registration in &closed {
429            inner.notify_module_changed(&registration.manifest.module_id);
430            let replaced = superseded_connections.contains(&registration.connection_id);
431            let end_reason = if replaced {
432                RegistrationEndReason::Replaced
433            } else {
434                reason
435            };
436            let replaced_by_connection_id = if replaced {
437                inner
438                    .superseded_by
439                    .remove(&registration.connection_id)
440                    .map(ConnectionId::get)
441            } else {
442                None
443            };
444            if let Some(replaced_by_connection_id) = replaced_by_connection_id {
445                info!(
446                    target: "subc.registry",
447                    module_id = %registration.manifest.module_id,
448                    connection_id = registration.connection_id.get(),
449                    reason = %end_reason.as_str(),
450                    replaced_by_connection_id,
451                    "module registration ended"
452                );
453                continue;
454            }
455            info!(
456                target: "subc.registry",
457                module_id = %registration.manifest.module_id,
458                connection_id = registration.connection_id.get(),
459                reason = %end_reason.as_str(),
460                "module registration ended"
461            );
462        }
463        Ok(closed)
464    }
465
466    fn lock_inner(&self) -> Result<MutexGuard<'_, RegistryInner>, RegistryError> {
467        self.inner.lock().map_err(|_| RegistryError::Poisoned)
468    }
469}
470
471/// Which internal slot a connection-keyed lookup found its registration in.
472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
473enum SlotKind {
474    Active,
475    Candidate,
476    Superseded,
477}
478
479impl RegistryInner {
480    fn notify_module_changed(&self, module_id: &str) {
481        if let Some(sender) = self.module_changes.get(module_id) {
482            sender.send_replace(());
483        }
484    }
485
486    fn find_by_connection(
487        &self,
488        connection_id: ConnectionId,
489    ) -> Option<(SlotKind, &ModuleRegistration)> {
490        let owned_by =
491            |registration: &&ModuleRegistration| registration.connection_id == connection_id;
492        self.modules
493            .values()
494            .find(owned_by)
495            .map(|registration| (SlotKind::Active, registration))
496            .or_else(|| {
497                self.candidates
498                    .values()
499                    .find(owned_by)
500                    .map(|registration| (SlotKind::Candidate, registration))
501            })
502            .or_else(|| {
503                self.superseded
504                    .iter()
505                    .find(owned_by)
506                    .map(|registration| (SlotKind::Superseded, registration))
507            })
508    }
509
510    fn registration_mut(
511        &mut self,
512        slot: SlotKind,
513        connection_id: ConnectionId,
514    ) -> Option<&mut ModuleRegistration> {
515        let owned_by =
516            |registration: &&mut ModuleRegistration| registration.connection_id == connection_id;
517        match slot {
518            SlotKind::Active => self.modules.values_mut().find(owned_by),
519            SlotKind::Candidate => self.candidates.values_mut().find(owned_by),
520            SlotKind::Superseded => self.superseded.iter_mut().find(owned_by),
521        }
522    }
523
524    fn close_module(&mut self, module_id: &str) -> Option<ModuleRegistration> {
525        let mut registration = self.modules.remove(module_id)?;
526        registration.state = ChannelState::Closed;
527        self.bump_generation();
528        Some(registration)
529    }
530
531    fn bump_generation(&mut self) {
532        self.generation = self.generation.wrapping_add(1);
533    }
534}
535
536#[derive(Debug, Clone, PartialEq, Eq)]
537pub enum RegistryError {
538    DuplicateModuleId {
539        module_id: String,
540    },
541    /// The id is unusable as a single path component. Enforced at
542    /// registration because the daemon MINTS A STORAGE DESCRIPTOR from the
543    /// self-claimed id verbatim (`<data_home>/cortexkit/<module_id>/store.db`),
544    /// so an id carrying separators or dot components is a path-traversal or
545    /// store-collision primitive handed to whoever claims it (issue #32). The
546    /// derivations deliberately do NOT sanitize instead: sanitizing here would
547    /// silently re-path every deployed store and desynchronize the Rust and TS
548    /// derivations, while refusal changes nothing for any id that ever worked.
549    PathHazardModuleId {
550        module_id: String,
551        reason: String,
552    },
553    Poisoned,
554}
555
556impl fmt::Display for RegistryError {
557    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
558        match self {
559            Self::DuplicateModuleId { module_id } => {
560                write!(f, "module_id '{module_id}' is already registered")
561            }
562            Self::PathHazardModuleId { module_id, reason } => {
563                write!(
564                    f,
565                    "module_id '{}' is not usable as a path component: {reason}",
566                    module_id.escape_debug()
567                )
568            }
569            Self::Poisoned => write!(f, "registry lock was poisoned"),
570        }
571    }
572}
573
574impl Error for RegistryError {}
575
576/// Why `module_id` cannot serve as a single path component, or `Ok(())`.
577///
578/// This is a REFUSAL predicate, not a sanitizer: every currently-working fleet
579/// id passes untouched, and anything refused here never worked meaningfully --
580/// it either escaped `<data_home>/cortexkit/` (separators, dot components) or
581/// aliased another module's store (`a/b` vs `a//b` collapsing on POSIX).
582/// Colons are allowed: reserved-namespace children (`mcp:...`) register today
583/// and a colon cannot traverse. Windows path legality is the store library's
584/// concern, not an identity rule.
585pub fn module_id_path_hazard(module_id: &str) -> Result<(), String> {
586    if module_id.is_empty() {
587        return Err("empty".to_string());
588    }
589    if module_id.contains('/') || module_id.contains('\\') {
590        return Err("contains a path separator".to_string());
591    }
592    if module_id == "." || module_id == ".." {
593        return Err("is a dot path component".to_string());
594    }
595    if module_id.chars().any(|c| c.is_control()) {
596        return Err("contains a control character".to_string());
597    }
598    // `module_id` is already one literal store-path component, so this reports
599    // the existing failure before derivation instead of adding a restriction.
600    // NAME_MAX is 255 UTF-8 bytes, which `str::len()` measures.
601    if module_id.len() > 255 {
602        return Err("is longer than 255 bytes".to_string());
603    }
604    Ok(())
605}
606
607#[cfg(test)]
608mod change_notification_tests {
609    use super::*;
610
611    fn manifest() -> ModuleManifest {
612        ModuleManifest::builder("watched", "1.0.0")
613            .protocol_ver(1)
614            .build()
615    }
616
617    fn changed(receiver: &mut watch::Receiver<()>) {
618        assert!(
619            receiver.has_changed().unwrap(),
620            "registry write must notify"
621        );
622        receiver.borrow_and_update();
623    }
624
625    #[test]
626    fn every_registration_write_notifies_only_its_module() {
627        let registry = Registry::default();
628        let mut events = registry.subscribe_module_changes("watched").unwrap();
629        let other = registry.subscribe_module_changes("other").unwrap();
630        let active = ConnectionId::new(1);
631        let candidate = ConnectionId::new(2);
632        registry
633            .register_with_control_ops(manifest(), 1, active, vec![])
634            .unwrap();
635        changed(&mut events);
636        registry
637            .set_module_state_for_test("watched", ChannelState::Active)
638            .unwrap();
639        changed(&mut events);
640        registry
641            .replace_catalog_for_connection(active, vec![], None, Some(true))
642            .unwrap();
643        changed(&mut events);
644        registry
645            .register_candidate_with_control_ops(manifest(), 1, candidate, vec![])
646            .unwrap();
647        changed(&mut events);
648        registry
649            .replace_catalog_for_connection(candidate, vec![], None, Some(true))
650            .unwrap();
651        changed(&mut events);
652        registry.promote_candidate("watched").unwrap().unwrap();
653        changed(&mut events);
654        // Superseded connections can still update and deregister their slot.
655        registry
656            .replace_catalog_for_connection(active, vec![], None, Some(false))
657            .unwrap();
658        changed(&mut events);
659        registry.deregister_connection(active).unwrap();
660        changed(&mut events);
661        registry.deregister_connection(candidate).unwrap();
662        changed(&mut events);
663        registry
664            .register_candidate_with_control_ops(manifest(), 1, candidate, vec![])
665            .unwrap();
666        changed(&mut events);
667        registry.deregister_connection(candidate).unwrap();
668        changed(&mut events);
669        assert!(
670            !other.has_changed().unwrap(),
671            "other module must stay parked"
672        );
673    }
674
675    #[test]
676    fn subscription_before_read_keeps_a_change_until_waited_on() {
677        let registry = Registry::default();
678        let mut events = registry.subscribe_module_changes("watched").unwrap();
679        assert!(registry.get_module("watched").unwrap().is_none());
680        registry
681            .register_with_control_ops(manifest(), 1, ConnectionId::new(1), vec![])
682            .unwrap();
683        // Neither a later lookup nor a coalesced catalog update consumes it.
684        registry.get_module("watched").unwrap().unwrap();
685        registry
686            .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
687            .unwrap();
688        changed(&mut events);
689        assert!(!events.has_changed().unwrap());
690    }
691}
692
693#[cfg(test)]
694mod path_hazard_tests {
695    use super::*;
696    use crate::ConnectionId;
697    use subc_protocol::manifest::ModuleManifest;
698
699    fn manifest(module_id: &str) -> ModuleManifest {
700        ModuleManifest::builder(module_id, "0.1.0")
701            .protocol_ver(1)
702            .build()
703    }
704
705    #[test]
706    fn path_hazard_ids_are_refused_and_nothing_registers() {
707        let registry = Registry::default();
708        for (bad, reason_fragment) in [
709            ("../escape", "path separator"),
710            ("a/b", "path separator"),
711            ("a\\b", "path separator"),
712            ("..", "dot path component"),
713            (".", "dot path component"),
714            ("", "empty"),
715            ("evil\u{0}id", "control character"),
716        ] {
717            let err = registry
718                .register_with_control_ops(manifest(bad), 1, ConnectionId::new(7), Vec::new())
719                .expect_err("path-hazard id must refuse");
720            // Reason asserted so a predicate throwing the WRONG refusal fails.
721            assert!(
722                err.to_string().contains(reason_fragment),
723                "id {bad:?}: expected {reason_fragment:?} in {err}"
724            );
725        }
726        // THE EFFECT, not just the verdicts: no refusal left a registration
727        // behind, and the generation never moved.
728        assert_eq!(registry.active_registration_count().unwrap(), 0);
729        assert_eq!(registry.generation().unwrap(), 0);
730    }
731
732    #[test]
733    fn module_id_path_component_length_matches_shared_refusal_vectors() {
734        let doc: serde_json::Value = serde_json::from_str(include_str!(
735            "../tests/golden/module_id_path_component_refusals.json"
736        ))
737        .expect("refusal fixture parses");
738
739        for case in doc["vectors"].as_array().expect("vectors array") {
740            let name = case["name"].as_str().expect("name");
741            let module_id = case["module_id"]["unit"]
742                .as_str()
743                .expect("module_id unit")
744                .repeat(
745                    case["module_id"]["repeat"]
746                        .as_u64()
747                        .expect("module_id repeat") as usize,
748                );
749            assert_eq!(
750                module_id.len(),
751                case["utf8_bytes"].as_u64().expect("utf8 bytes") as usize
752            );
753
754            let expected = case["expect_reason"].as_str().map(str::to_owned);
755            assert_eq!(
756                module_id_path_hazard(&module_id).err(),
757                expected,
758                "shared refusal vector {name:?} diverged"
759            );
760        }
761    }
762
763    #[test]
764    fn working_id_shapes_register_including_namespace_colons() {
765        let registry = Registry::default();
766        for (i, good) in ["magic-context", "mcp:everything", "v1.2-module"]
767            .iter()
768            .enumerate()
769        {
770            registry
771                .register_with_control_ops(
772                    manifest(good),
773                    1,
774                    ConnectionId::new(10 + i as u64),
775                    Vec::new(),
776                )
777                .unwrap_or_else(|err| panic!("id {good:?} must register: {err}"));
778        }
779        assert_eq!(registry.active_registration_count().unwrap(), 3);
780    }
781}
782
783#[cfg(test)]
784mod swap_slot_tests {
785    use super::*;
786
787    fn manifest(module_id: &str, ready: Option<bool>) -> ModuleManifest {
788        let mut manifest = ModuleManifest::builder(module_id, "0.1.0").build();
789        manifest.ready = ready;
790        manifest
791    }
792
793    const INCUMBENT: ConnectionId = ConnectionId(1);
794    const CANDIDATE: ConnectionId = ConnectionId(2);
795
796    fn registry_with_candidate() -> Registry {
797        let registry = Registry::default();
798        registry
799            .register_with_control_ops(manifest("m", None), 1, INCUMBENT, Vec::new())
800            .unwrap();
801        registry
802            .register_candidate_with_control_ops(
803                manifest("m", Some(false)),
804                1,
805                CANDIDATE,
806                Vec::new(),
807            )
808            .unwrap();
809        registry
810    }
811
812    #[test]
813    fn candidate_is_invisible_to_by_id_lookups_and_listing() {
814        let registry = registry_with_candidate();
815        let generation = registry.generation().unwrap();
816        assert_eq!(
817            registry.get_module("m").unwrap().unwrap().connection_id,
818            INCUMBENT
819        );
820        let (listed_generation, listed) = registry.list_modules().unwrap();
821        assert_eq!(listed.len(), 1);
822        assert_eq!(listed[0].connection_id, INCUMBENT);
823        assert_eq!(listed_generation, generation);
824        assert_eq!(registry.active_registration_count().unwrap(), 1);
825        assert_eq!(
826            registry.get_candidate("m").unwrap().unwrap().connection_id,
827            CANDIDATE
828        );
829        assert_eq!(
830            registry
831                .register_candidate_with_control_ops(
832                    manifest("m", None),
833                    1,
834                    ConnectionId(3),
835                    Vec::new()
836                )
837                .unwrap_err(),
838            RegistryError::DuplicateModuleId {
839                module_id: "m".to_string()
840            }
841        );
842    }
843
844    /// Without the candidate slot in the connection-keyed search, this update
845    /// returns `Ok(None)` and the candidate never becomes ready.
846    #[test]
847    fn candidate_catalog_update_reaches_the_candidate_registration() {
848        let registry = registry_with_candidate();
849        assert!(!registry.get_candidate("m").unwrap().unwrap().ready);
850
851        let updated = registry
852            .replace_catalog_for_connection(CANDIDATE, Vec::new(), None, Some(true))
853            .unwrap()
854            .expect("the candidate's own connection finds its registration");
855
856        assert_eq!(updated.connection_id, CANDIDATE);
857        assert!(registry.get_candidate("m").unwrap().unwrap().ready);
858        assert_eq!(
859            registry.get_module_by_connection(CANDIDATE).unwrap(),
860            Some(updated)
861        );
862        assert_eq!(
863            registry.get_module("m").unwrap().unwrap().connection_id,
864            INCUMBENT,
865            "a candidate's update must not touch the active registration"
866        );
867    }
868
869    #[test]
870    fn promotion_swaps_slots_and_each_connection_still_deregisters_its_own() {
871        let registry = registry_with_candidate();
872        let before = registry.generation().unwrap();
873        let cutover = registry.promote_candidate("m").unwrap().unwrap();
874        assert_eq!(cutover.promoted.connection_id, CANDIDATE);
875        assert_eq!(cutover.superseded.unwrap().connection_id, INCUMBENT);
876        assert_ne!(registry.generation().unwrap(), before);
877        assert_eq!(registry.promote_candidate("m").unwrap(), None);
878
879        assert_eq!(
880            registry
881                .registration(RegistrationSlot::Active("m"))
882                .unwrap()
883                .unwrap()
884                .connection_id,
885            CANDIDATE
886        );
887        assert!(registry
888            .registration(RegistrationSlot::Candidate("m"))
889            .unwrap()
890            .is_none());
891        assert!(registry
892            .registration(RegistrationSlot::Connection(INCUMBENT))
893            .unwrap()
894            .is_some());
895
896        let closed = registry.deregister_connection(INCUMBENT).unwrap();
897        assert_eq!(closed.len(), 1);
898        assert_eq!(closed[0].connection_id, INCUMBENT);
899        assert_eq!(closed[0].state, ChannelState::Closed);
900        assert!(registry
901            .registration(RegistrationSlot::Connection(INCUMBENT))
902            .unwrap()
903            .is_none());
904        assert_eq!(
905            registry.get_module("m").unwrap().unwrap().connection_id,
906            CANDIDATE
907        );
908    }
909
910    #[test]
911    fn a_dropped_candidate_deregisters_from_the_candidate_slot_only() {
912        let registry = registry_with_candidate();
913        let closed = registry.deregister_connection(CANDIDATE).unwrap();
914        assert_eq!(closed.len(), 1);
915        assert_eq!(closed[0].connection_id, CANDIDATE);
916        assert!(registry.get_candidate("m").unwrap().is_none());
917        assert_eq!(
918            registry.get_module("m").unwrap().unwrap().connection_id,
919            INCUMBENT
920        );
921    }
922}