Skip to main content

subc_daemon/
registry.rs

1use std::{
2    collections::{HashMap, HashSet},
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    /// Connections whose registration is reserved but not yet published.
135    ///
136    /// A HELLO registers in the registry before the daemon installs the
137    /// module's forwarding endpoint, and until that endpoint exists a
138    /// `route.open` to the module cannot be relayed. A registration in this set
139    /// already holds its module id (a duplicate HELLO is refused) and is found
140    /// by every lookup except two: [`Registry::list_modules`], which feeds
141    /// `catalog.list`, and [`Registry::published_registration`], which the swap
142    /// supervisor reads to decide that a candidate has registered. Adding a
143    /// registration to this set does not bump the catalog generation;
144    /// [`Registry::publish_registration`] removes it and bumps the generation
145    /// in the same critical section, so a catalog reader sees the module and
146    /// the new generation together.
147    unpublished: HashSet<ConnectionId>,
148    generation: u64,
149}
150
151impl Registry {
152    /// Subscribe before reading a registration so a change between the read
153    /// and the supervisor's wait remains pending on this receiver.
154    pub(crate) fn subscribe_module_changes(
155        &self,
156        module_id: &str,
157    ) -> Result<watch::Receiver<()>, RegistryError> {
158        let mut inner = self.lock_inner()?;
159        // Retired supervisors must not leave an ever-growing set of ids.
160        inner
161            .module_changes
162            .retain(|_, sender| sender.receiver_count() > 0);
163        Ok(inner
164            .module_changes
165            .entry(module_id.to_string())
166            .or_insert_with(|| watch::channel(()).0)
167            .subscribe())
168    }
169
170    /// Register a module manifest with the module's effective granted control op set.
171    ///
172    /// The registration is published at once: it is listed by
173    /// [`Self::list_modules`] and bumps the catalog generation. The HELLO path
174    /// uses [`Self::register_unpublished_with_control_ops`] instead, because it
175    /// still has to install the module's forwarding endpoint.
176    pub fn register_with_control_ops(
177        &self,
178        manifest: ModuleManifest,
179        negotiated_ver: u8,
180        connection_id: ConnectionId,
181        control_ops: Vec<String>,
182    ) -> Result<ModuleRegistration, RegistryError> {
183        self.register_active(manifest, negotiated_ver, connection_id, control_ops, true)
184    }
185
186    /// Register a module manifest without listing it yet.
187    ///
188    /// The registration holds its module id and is visible to every lookup
189    /// except [`Self::list_modules`] (and so `catalog.list`) and
190    /// [`Self::published_registration`]. The catalog generation does not move.
191    /// Call [`Self::publish_registration`] once the module can actually be
192    /// routed to; if that never becomes possible, deregistering the connection
193    /// removes the entry without the catalog ever having listed it.
194    pub fn register_unpublished_with_control_ops(
195        &self,
196        manifest: ModuleManifest,
197        negotiated_ver: u8,
198        connection_id: ConnectionId,
199        control_ops: Vec<String>,
200    ) -> Result<ModuleRegistration, RegistryError> {
201        self.register_active(manifest, negotiated_ver, connection_id, control_ops, false)
202    }
203
204    fn register_active(
205        &self,
206        manifest: ModuleManifest,
207        negotiated_ver: u8,
208        connection_id: ConnectionId,
209        control_ops: Vec<String>,
210        publish: bool,
211    ) -> Result<ModuleRegistration, RegistryError> {
212        let module_id = manifest.module_id.clone();
213        if let Err(reason) = module_id_path_hazard(&module_id) {
214            return Err(RegistryError::PathHazardModuleId { module_id, reason });
215        }
216        let mut inner = self.lock_inner()?;
217        if inner.modules.contains_key(&module_id) {
218            return Err(RegistryError::DuplicateModuleId { module_id });
219        }
220
221        let ready = manifest.ready.unwrap_or(true);
222        let registration = ModuleRegistration {
223            manifest,
224            ready,
225            negotiated_ver,
226            state: ChannelState::Active,
227            connection_id,
228            control_ops,
229        };
230
231        inner.modules.insert(module_id, registration.clone());
232        if publish {
233            inner.bump_generation();
234        } else {
235            inner.unpublished.insert(connection_id);
236        }
237        inner.notify_module_changed(&registration.manifest.module_id);
238        Ok(registration)
239    }
240
241    /// Register a swap candidate for `manifest.module_id` into the candidate slot.
242    ///
243    /// The candidate is invisible to [`Self::get_module`], [`Self::list_modules`]
244    /// and every other by-id lookup until [`Self::promote_candidate`]. An active
245    /// registration for the id is not required, because the incumbent may die
246    /// while the swap is open; deciding whether a candidate may register at all
247    /// belongs to the caller that admits it. A second candidate for the same id
248    /// is refused.
249    ///
250    /// The candidate is published at once, meaning candidate-slot lookups
251    /// through [`Self::published_registration`] return it immediately. The
252    /// HELLO path uses
253    /// [`Self::register_unpublished_candidate_with_control_ops`] instead.
254    pub fn register_candidate_with_control_ops(
255        &self,
256        manifest: ModuleManifest,
257        negotiated_ver: u8,
258        connection_id: ConnectionId,
259        control_ops: Vec<String>,
260    ) -> Result<ModuleRegistration, RegistryError> {
261        self.register_candidate(manifest, negotiated_ver, connection_id, control_ops, true)
262    }
263
264    /// Register a swap candidate that [`Self::published_registration`] does not
265    /// return until [`Self::publish_registration`] is called for its
266    /// connection. The swap supervisor reads that lookup to decide the
267    /// candidate has registered, and it promotes a ready candidate straight
268    /// away; promoting one whose forwarding endpoint is not installed yet would
269    /// fail the cutover.
270    pub fn register_unpublished_candidate_with_control_ops(
271        &self,
272        manifest: ModuleManifest,
273        negotiated_ver: u8,
274        connection_id: ConnectionId,
275        control_ops: Vec<String>,
276    ) -> Result<ModuleRegistration, RegistryError> {
277        self.register_candidate(manifest, negotiated_ver, connection_id, control_ops, false)
278    }
279
280    fn register_candidate(
281        &self,
282        manifest: ModuleManifest,
283        negotiated_ver: u8,
284        connection_id: ConnectionId,
285        control_ops: Vec<String>,
286        publish: bool,
287    ) -> Result<ModuleRegistration, RegistryError> {
288        let module_id = manifest.module_id.clone();
289        if let Err(reason) = module_id_path_hazard(&module_id) {
290            return Err(RegistryError::PathHazardModuleId { module_id, reason });
291        }
292        let mut inner = self.lock_inner()?;
293        if inner.candidates.contains_key(&module_id) {
294            return Err(RegistryError::DuplicateModuleId { module_id });
295        }
296        let ready = manifest.ready.unwrap_or(true);
297        let registration = ModuleRegistration {
298            manifest,
299            ready,
300            negotiated_ver,
301            state: ChannelState::Active,
302            connection_id,
303            control_ops,
304        };
305        inner.candidates.insert(module_id, registration.clone());
306        if !publish {
307            inner.unpublished.insert(connection_id);
308        }
309        inner.notify_module_changed(&registration.manifest.module_id);
310        Ok(registration)
311    }
312
313    /// Publish the registration `connection_id` holds, in whichever slot.
314    ///
315    /// For an active registration this is the moment `catalog.list` starts
316    /// listing the module, and the catalog generation advances under the same
317    /// lock, so no reader can see the new generation without the module or the
318    /// module without the new generation. A candidate is not listed either way;
319    /// publishing it only makes it visible to
320    /// [`Self::published_registration`].
321    ///
322    /// Returns `false` when there is nothing to publish: the connection holds
323    /// no registration any more, or it was already published.
324    pub fn publish_registration(&self, connection_id: ConnectionId) -> Result<bool, RegistryError> {
325        let mut inner = self.lock_inner()?;
326        if !inner.unpublished.remove(&connection_id) {
327            return Ok(false);
328        }
329        let Some((slot, registration)) = inner.find_by_connection(connection_id) else {
330            return Ok(false);
331        };
332        let module_id = registration.manifest.module_id.clone();
333        if matches!(slot, SlotKind::Active) {
334            inner.bump_generation();
335        }
336        inner.notify_module_changed(&module_id);
337        Ok(true)
338    }
339
340    /// Move the candidate for `module_id` into the active slot and demote the
341    /// previous active registration, in one registry critical section.
342    ///
343    /// Returns `Ok(None)` when there is no candidate to promote. Bumps the
344    /// catalog generation, because the listed registration for the id changed.
345    pub fn promote_candidate(
346        &self,
347        module_id: &str,
348    ) -> Result<Option<RegistryCutover>, RegistryError> {
349        let mut inner = self.lock_inner()?;
350        let Some(promoted) = inner.candidates.remove(module_id) else {
351            return Ok(None);
352        };
353        let superseded = inner
354            .modules
355            .insert(module_id.to_string(), promoted.clone());
356        if let Some(superseded) = superseded.clone() {
357            let replaced_connection_id = superseded.connection_id.get();
358            inner
359                .superseded_by
360                .insert(superseded.connection_id, promoted.connection_id);
361            inner.superseded.push(superseded);
362            info!(
363                target: "subc.registry",
364                module_id = %module_id,
365                connection_id = promoted.connection_id.get(),
366                replaced = true,
367                replaced_connection_id,
368                "module registration promoted"
369            );
370        }
371        inner.bump_generation();
372        inner.notify_module_changed(module_id);
373        Ok(Some(RegistryCutover {
374            promoted,
375            superseded,
376        }))
377    }
378
379    /// The ACTIVE registration for `module_id`. Candidates and superseded
380    /// incumbents are never returned: this is the lookup routing decisions use.
381    pub fn get_module(&self, module_id: &str) -> Result<Option<ModuleRegistration>, RegistryError> {
382        Ok(self.lock_inner()?.modules.get(module_id).cloned())
383    }
384
385    /// The swap candidate registered for `module_id`, if any.
386    pub fn get_candidate(
387        &self,
388        module_id: &str,
389    ) -> Result<Option<ModuleRegistration>, RegistryError> {
390        Ok(self.lock_inner()?.candidates.get(module_id).cloned())
391    }
392
393    /// The registration held in one specific slot. See [`RegistrationSlot`].
394    pub fn registration(
395        &self,
396        slot: RegistrationSlot<'_>,
397    ) -> Result<Option<ModuleRegistration>, RegistryError> {
398        let inner = self.lock_inner()?;
399        Ok(match slot {
400            RegistrationSlot::Active(module_id) => inner.modules.get(module_id).cloned(),
401            RegistrationSlot::Candidate(module_id) => inner.candidates.get(module_id).cloned(),
402            RegistrationSlot::Connection(connection_id) => inner
403                .find_by_connection(connection_id)
404                .map(|(_, registration)| registration.clone()),
405        })
406    }
407
408    pub fn active_registration_count(&self) -> Result<usize, RegistryError> {
409        Ok(self.lock_inner()?.modules.len())
410    }
411
412    /// The catalog generation and the active registrations `catalog.list`
413    /// shows. Unpublished registrations (see
414    /// [`Self::register_unpublished_with_control_ops`]) are left out.
415    pub fn list_modules(&self) -> Result<(u64, Vec<ModuleRegistration>), RegistryError> {
416        let inner = self.lock_inner()?;
417        let mut modules = inner
418            .modules
419            .values()
420            .filter(|registration| !inner.unpublished.contains(&registration.connection_id))
421            .cloned()
422            .collect::<Vec<_>>();
423        modules.sort_by(|left, right| left.manifest.module_id.cmp(&right.manifest.module_id));
424        Ok((inner.generation, modules))
425    }
426
427    /// Every active registration, including unpublished ones.
428    ///
429    /// For the capability evaluator and the capability deny census, which
430    /// count a module as soon as it has registered. Whether `catalog.list`
431    /// shows the module yet does not change their verdicts.
432    pub fn list_registered_modules(&self) -> Result<Vec<ModuleRegistration>, RegistryError> {
433        let inner = self.lock_inner()?;
434        let mut modules = inner.modules.values().cloned().collect::<Vec<_>>();
435        modules.sort_by(|left, right| left.manifest.module_id.cmp(&right.manifest.module_id));
436        Ok(modules)
437    }
438
439    /// Like [`Self::registration`], but a registration that is not published
440    /// yet is reported as absent.
441    pub fn published_registration(
442        &self,
443        slot: RegistrationSlot<'_>,
444    ) -> Result<Option<ModuleRegistration>, RegistryError> {
445        let inner = self.lock_inner()?;
446        let registration = match slot {
447            RegistrationSlot::Active(module_id) => inner.modules.get(module_id),
448            RegistrationSlot::Candidate(module_id) => inner.candidates.get(module_id),
449            RegistrationSlot::Connection(connection_id) => inner
450                .find_by_connection(connection_id)
451                .map(|(_, registration)| registration),
452        };
453        Ok(registration
454            .filter(|registration| !inner.unpublished.contains(&registration.connection_id))
455            .cloned())
456    }
457
458    pub fn generation(&self) -> Result<u64, RegistryError> {
459        Ok(self.lock_inner()?.generation)
460    }
461
462    #[cfg(test)]
463    pub(crate) fn set_module_state_for_test(
464        &self,
465        module_id: &str,
466        state: ChannelState,
467    ) -> Result<bool, RegistryError> {
468        let mut inner = self.lock_inner()?;
469        let Some(registration) = inner.modules.get_mut(module_id) else {
470            return Ok(false);
471        };
472        registration.state = state;
473        inner.notify_module_changed(module_id);
474        Ok(true)
475    }
476
477    /// The registration owned by `connection_id`, searching the active,
478    /// candidate and superseded slots in that order.
479    pub fn get_module_by_connection(
480        &self,
481        connection_id: ConnectionId,
482    ) -> Result<Option<ModuleRegistration>, RegistryError> {
483        Ok(self
484            .lock_inner()?
485            .find_by_connection(connection_id)
486            .map(|(_, registration)| registration.clone()))
487    }
488
489    /// Replace the provider role list and, when supplied, the attested capability
490    /// declaration for the module owned by `connection_id`.
491    ///
492    /// Searches every slot: a swap candidate declares itself ready through this
493    /// call, and if only the active slot were searched its update would find
494    /// nothing and the candidate would never become ready. Only a change to the
495    /// active slot bumps the catalog generation, because only the active slot is
496    /// listed, and only once that registration is published.
497    pub fn replace_catalog_for_connection(
498        &self,
499        connection_id: ConnectionId,
500        provides: Vec<ProviderRole>,
501        capabilities: Option<CapabilityDeclarations>,
502        ready: Option<bool>,
503    ) -> Result<Option<ModuleRegistration>, RegistryError> {
504        let mut inner = self.lock_inner()?;
505        let Some((slot, _)) = inner.find_by_connection(connection_id) else {
506            return Ok(None);
507        };
508        let registration = inner
509            .registration_mut(slot, connection_id)
510            .expect("registration discovered under the same registry lock must still exist");
511        registration.manifest.provides = provides;
512        if let Some(capabilities) = capabilities {
513            registration.manifest.capabilities = Some(capabilities);
514        }
515        if let Some(ready) = ready {
516            registration.ready = ready;
517            registration.manifest.ready = Some(ready);
518        }
519        let updated = registration.clone();
520        if matches!(slot, SlotKind::Active) && !inner.unpublished.contains(&connection_id) {
521            inner.bump_generation();
522        }
523        inner.notify_module_changed(&updated.manifest.module_id);
524        Ok(Some(updated))
525    }
526
527    /// Deregister every module owned by a dropped connection, in any slot.
528    pub fn deregister_connection(
529        &self,
530        connection_id: ConnectionId,
531    ) -> Result<Vec<ModuleRegistration>, RegistryError> {
532        self.deregister_connection_with_reason(
533            connection_id,
534            RegistrationEndReason::ConnectionClosed,
535        )
536    }
537
538    pub(crate) fn deregister_connection_with_reason(
539        &self,
540        connection_id: ConnectionId,
541        reason: RegistrationEndReason,
542    ) -> Result<Vec<ModuleRegistration>, RegistryError> {
543        let mut inner = self.lock_inner()?;
544        // A registration that was never published was never listed, so
545        // removing it leaves the catalog, and its generation, as they were.
546        let was_published = !inner.unpublished.remove(&connection_id);
547        let module_ids: Vec<String> = inner
548            .modules
549            .iter()
550            .filter(|(_, registration)| registration.connection_id == connection_id)
551            .map(|(module_id, _)| module_id.clone())
552            .collect();
553
554        let mut closed: Vec<ModuleRegistration> = module_ids
555            .into_iter()
556            .filter_map(|module_id| inner.close_module(&module_id, was_published))
557            .collect();
558
559        let candidate_ids: Vec<String> = inner
560            .candidates
561            .iter()
562            .filter(|(_, registration)| registration.connection_id == connection_id)
563            .map(|(module_id, _)| module_id.clone())
564            .collect();
565        for module_id in candidate_ids {
566            if let Some(mut registration) = inner.candidates.remove(&module_id) {
567                registration.state = ChannelState::Closed;
568                closed.push(registration);
569            }
570        }
571
572        let (removed, kept): (Vec<_>, Vec<_>) = std::mem::take(&mut inner.superseded)
573            .into_iter()
574            .partition(|registration| registration.connection_id == connection_id);
575        let superseded_connections: std::collections::HashSet<_> = removed
576            .iter()
577            .map(|registration| registration.connection_id)
578            .collect();
579        inner.superseded = kept;
580        closed.extend(removed.into_iter().map(|mut registration| {
581            registration.state = ChannelState::Closed;
582            registration
583        }));
584        for registration in &closed {
585            inner.notify_module_changed(&registration.manifest.module_id);
586            let replaced = superseded_connections.contains(&registration.connection_id);
587            let end_reason = if replaced {
588                RegistrationEndReason::Replaced
589            } else {
590                reason
591            };
592            let replaced_by_connection_id = if replaced {
593                inner
594                    .superseded_by
595                    .remove(&registration.connection_id)
596                    .map(ConnectionId::get)
597            } else {
598                None
599            };
600            if let Some(replaced_by_connection_id) = replaced_by_connection_id {
601                info!(
602                    target: "subc.registry",
603                    module_id = %registration.manifest.module_id,
604                    connection_id = registration.connection_id.get(),
605                    reason = %end_reason.as_str(),
606                    replaced_by_connection_id,
607                    "module registration ended"
608                );
609                continue;
610            }
611            info!(
612                target: "subc.registry",
613                module_id = %registration.manifest.module_id,
614                connection_id = registration.connection_id.get(),
615                reason = %end_reason.as_str(),
616                "module registration ended"
617            );
618        }
619        Ok(closed)
620    }
621
622    fn lock_inner(&self) -> Result<MutexGuard<'_, RegistryInner>, RegistryError> {
623        self.inner.lock().map_err(|_| RegistryError::Poisoned)
624    }
625}
626
627/// Which internal slot a connection-keyed lookup found its registration in.
628#[derive(Debug, Clone, Copy, PartialEq, Eq)]
629enum SlotKind {
630    Active,
631    Candidate,
632    Superseded,
633}
634
635impl RegistryInner {
636    fn notify_module_changed(&self, module_id: &str) {
637        if let Some(sender) = self.module_changes.get(module_id) {
638            sender.send_replace(());
639        }
640    }
641
642    fn find_by_connection(
643        &self,
644        connection_id: ConnectionId,
645    ) -> Option<(SlotKind, &ModuleRegistration)> {
646        let owned_by =
647            |registration: &&ModuleRegistration| registration.connection_id == connection_id;
648        self.modules
649            .values()
650            .find(owned_by)
651            .map(|registration| (SlotKind::Active, registration))
652            .or_else(|| {
653                self.candidates
654                    .values()
655                    .find(owned_by)
656                    .map(|registration| (SlotKind::Candidate, registration))
657            })
658            .or_else(|| {
659                self.superseded
660                    .iter()
661                    .find(owned_by)
662                    .map(|registration| (SlotKind::Superseded, registration))
663            })
664    }
665
666    fn registration_mut(
667        &mut self,
668        slot: SlotKind,
669        connection_id: ConnectionId,
670    ) -> Option<&mut ModuleRegistration> {
671        let owned_by =
672            |registration: &&mut ModuleRegistration| registration.connection_id == connection_id;
673        match slot {
674            SlotKind::Active => self.modules.values_mut().find(owned_by),
675            SlotKind::Candidate => self.candidates.values_mut().find(owned_by),
676            SlotKind::Superseded => self.superseded.iter_mut().find(owned_by),
677        }
678    }
679
680    fn close_module(&mut self, module_id: &str, listed: bool) -> Option<ModuleRegistration> {
681        let mut registration = self.modules.remove(module_id)?;
682        registration.state = ChannelState::Closed;
683        if listed {
684            self.bump_generation();
685        }
686        Some(registration)
687    }
688
689    fn bump_generation(&mut self) {
690        self.generation = self.generation.wrapping_add(1);
691    }
692}
693
694#[derive(Debug, Clone, PartialEq, Eq)]
695pub enum RegistryError {
696    DuplicateModuleId {
697        module_id: String,
698    },
699    /// The id is unusable as a single path component. Enforced at
700    /// registration because the daemon MINTS A STORAGE DESCRIPTOR from the
701    /// self-claimed id verbatim (`<data_home>/cortexkit/<module_id>/store.db`),
702    /// so an id carrying separators or dot components is a path-traversal or
703    /// store-collision primitive handed to whoever claims it (issue #32). The
704    /// derivations deliberately do NOT sanitize instead: sanitizing here would
705    /// silently re-path every deployed store and desynchronize the Rust and TS
706    /// derivations, while refusal changes nothing for any id that ever worked.
707    PathHazardModuleId {
708        module_id: String,
709        reason: String,
710    },
711    Poisoned,
712}
713
714impl fmt::Display for RegistryError {
715    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
716        match self {
717            Self::DuplicateModuleId { module_id } => {
718                write!(f, "module_id '{module_id}' is already registered")
719            }
720            Self::PathHazardModuleId { module_id, reason } => {
721                write!(
722                    f,
723                    "module_id '{}' is not usable as a path component: {reason}",
724                    module_id.escape_debug()
725                )
726            }
727            Self::Poisoned => write!(f, "registry lock was poisoned"),
728        }
729    }
730}
731
732impl Error for RegistryError {}
733
734/// Why `module_id` cannot serve as a single path component, or `Ok(())`.
735///
736/// This is a REFUSAL predicate, not a sanitizer: every currently-working fleet
737/// id passes untouched, and anything refused here never worked meaningfully --
738/// it either escaped `<data_home>/cortexkit/` (separators, dot components) or
739/// aliased another module's store (`a/b` vs `a//b` collapsing on POSIX).
740/// Colons are allowed: reserved-namespace children (`mcp:...`) register today
741/// and a colon cannot traverse. Windows path legality is the store library's
742/// concern, not an identity rule.
743pub fn module_id_path_hazard(module_id: &str) -> Result<(), String> {
744    if module_id.is_empty() {
745        return Err("empty".to_string());
746    }
747    if module_id.contains('/') || module_id.contains('\\') {
748        return Err("contains a path separator".to_string());
749    }
750    if module_id == "." || module_id == ".." {
751        return Err("is a dot path component".to_string());
752    }
753    if module_id.chars().any(|c| c.is_control()) {
754        return Err("contains a control character".to_string());
755    }
756    // `module_id` is already one literal store-path component, so this reports
757    // the existing failure before derivation instead of adding a restriction.
758    // NAME_MAX is 255 UTF-8 bytes, which `str::len()` measures.
759    if module_id.len() > 255 {
760        return Err("is longer than 255 bytes".to_string());
761    }
762    Ok(())
763}
764
765#[cfg(test)]
766mod change_notification_tests {
767    use super::*;
768
769    fn manifest() -> ModuleManifest {
770        ModuleManifest::builder("watched", "1.0.0")
771            .protocol_ver(1)
772            .build()
773    }
774
775    fn changed(receiver: &mut watch::Receiver<()>) {
776        assert!(
777            receiver.has_changed().unwrap(),
778            "registry write must notify"
779        );
780        receiver.borrow_and_update();
781    }
782
783    #[test]
784    fn every_registration_write_notifies_only_its_module() {
785        let registry = Registry::default();
786        let mut events = registry.subscribe_module_changes("watched").unwrap();
787        let other = registry.subscribe_module_changes("other").unwrap();
788        let active = ConnectionId::new(1);
789        let candidate = ConnectionId::new(2);
790        registry
791            .register_with_control_ops(manifest(), 1, active, vec![])
792            .unwrap();
793        changed(&mut events);
794        registry
795            .set_module_state_for_test("watched", ChannelState::Active)
796            .unwrap();
797        changed(&mut events);
798        registry
799            .replace_catalog_for_connection(active, vec![], None, Some(true))
800            .unwrap();
801        changed(&mut events);
802        registry
803            .register_candidate_with_control_ops(manifest(), 1, candidate, vec![])
804            .unwrap();
805        changed(&mut events);
806        registry
807            .replace_catalog_for_connection(candidate, vec![], None, Some(true))
808            .unwrap();
809        changed(&mut events);
810        registry.promote_candidate("watched").unwrap().unwrap();
811        changed(&mut events);
812        // Superseded connections can still update and deregister their slot.
813        registry
814            .replace_catalog_for_connection(active, vec![], None, Some(false))
815            .unwrap();
816        changed(&mut events);
817        registry.deregister_connection(active).unwrap();
818        changed(&mut events);
819        registry.deregister_connection(candidate).unwrap();
820        changed(&mut events);
821        registry
822            .register_candidate_with_control_ops(manifest(), 1, candidate, vec![])
823            .unwrap();
824        changed(&mut events);
825        registry.deregister_connection(candidate).unwrap();
826        changed(&mut events);
827        assert!(
828            !other.has_changed().unwrap(),
829            "other module must stay parked"
830        );
831    }
832
833    #[test]
834    fn subscription_before_read_keeps_a_change_until_waited_on() {
835        let registry = Registry::default();
836        let mut events = registry.subscribe_module_changes("watched").unwrap();
837        assert!(registry.get_module("watched").unwrap().is_none());
838        registry
839            .register_with_control_ops(manifest(), 1, ConnectionId::new(1), vec![])
840            .unwrap();
841        // Neither a later lookup nor a coalesced catalog update consumes it.
842        registry.get_module("watched").unwrap().unwrap();
843        registry
844            .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
845            .unwrap();
846        changed(&mut events);
847        assert!(!events.has_changed().unwrap());
848    }
849}
850
851#[cfg(test)]
852mod publication_tests {
853    use super::*;
854
855    fn manifest() -> ModuleManifest {
856        ModuleManifest::builder("m", "1.0.0")
857            .protocol_ver(1)
858            .build()
859    }
860
861    /// An unpublished registration holds its id and answers routing lookups,
862    /// but `list_modules` leaves it out and the generation stays put until
863    /// it is published; publishing lists it and bumps the generation once.
864    #[test]
865    fn unpublished_registration_is_unlisted_until_published() {
866        let registry = Registry::default();
867        let connection = ConnectionId::new(1);
868        registry
869            .register_unpublished_with_control_ops(manifest(), 1, connection, vec![])
870            .unwrap();
871        assert_eq!(registry.list_modules().unwrap(), (0, Vec::new()));
872        assert!(registry.get_module("m").unwrap().is_some());
873        assert_eq!(registry.list_registered_modules().unwrap().len(), 1);
874        assert!(registry
875            .published_registration(RegistrationSlot::Active("m"))
876            .unwrap()
877            .is_none());
878        assert!(matches!(
879            registry.register_with_control_ops(manifest(), 1, ConnectionId::new(2), vec![]),
880            Err(RegistryError::DuplicateModuleId { .. })
881        ));
882        // A catalog update before publication does not move the generation.
883        registry
884            .replace_catalog_for_connection(connection, vec![], None, Some(true))
885            .unwrap();
886        assert_eq!(registry.generation().unwrap(), 0);
887
888        assert!(registry.publish_registration(connection).unwrap());
889        let (generation, listed) = registry.list_modules().unwrap();
890        assert_eq!(generation, 1);
891        assert_eq!(listed.len(), 1);
892        assert!(!registry.publish_registration(connection).unwrap());
893        assert_eq!(registry.generation().unwrap(), 1);
894    }
895
896    /// Removing a registration that was never published leaves no catalog
897    /// trace: the generation does not move, and a later publish is a no-op.
898    #[test]
899    fn deregistering_an_unpublished_registration_leaves_the_generation() {
900        let registry = Registry::default();
901        let connection = ConnectionId::new(1);
902        registry
903            .register_unpublished_with_control_ops(manifest(), 1, connection, vec![])
904            .unwrap();
905        assert_eq!(registry.deregister_connection(connection).unwrap().len(), 1);
906        assert_eq!(registry.list_modules().unwrap(), (0, Vec::new()));
907        assert!(!registry.publish_registration(connection).unwrap());
908        assert_eq!(registry.generation().unwrap(), 0);
909    }
910
911    /// An unpublished candidate holds the candidate slot but
912    /// `published_registration` does not return it until it is published.
913    /// Publishing a candidate does not bump the generation: candidates are
914    /// never listed.
915    #[test]
916    fn unpublished_candidate_is_hidden_from_published_lookups() {
917        let registry = Registry::default();
918        let candidate = ConnectionId::new(2);
919        registry
920            .register_unpublished_candidate_with_control_ops(manifest(), 1, candidate, vec![])
921            .unwrap();
922        assert!(registry
923            .registration(RegistrationSlot::Candidate("m"))
924            .unwrap()
925            .is_some());
926        assert!(registry
927            .published_registration(RegistrationSlot::Candidate("m"))
928            .unwrap()
929            .is_none());
930        assert!(registry
931            .published_registration(RegistrationSlot::Connection(candidate))
932            .unwrap()
933            .is_none());
934        assert!(registry.publish_registration(candidate).unwrap());
935        assert!(registry
936            .published_registration(RegistrationSlot::Candidate("m"))
937            .unwrap()
938            .is_some());
939        assert_eq!(registry.generation().unwrap(), 0);
940    }
941}
942
943#[cfg(test)]
944mod path_hazard_tests {
945    use super::*;
946    use crate::ConnectionId;
947    use subc_protocol::manifest::ModuleManifest;
948
949    fn manifest(module_id: &str) -> ModuleManifest {
950        ModuleManifest::builder(module_id, "0.1.0")
951            .protocol_ver(1)
952            .build()
953    }
954
955    #[test]
956    fn path_hazard_ids_are_refused_and_nothing_registers() {
957        let registry = Registry::default();
958        for (bad, reason_fragment) in [
959            ("../escape", "path separator"),
960            ("a/b", "path separator"),
961            ("a\\b", "path separator"),
962            ("..", "dot path component"),
963            (".", "dot path component"),
964            ("", "empty"),
965            ("evil\u{0}id", "control character"),
966        ] {
967            let err = registry
968                .register_with_control_ops(manifest(bad), 1, ConnectionId::new(7), Vec::new())
969                .expect_err("path-hazard id must refuse");
970            // Reason asserted so a predicate throwing the WRONG refusal fails.
971            assert!(
972                err.to_string().contains(reason_fragment),
973                "id {bad:?}: expected {reason_fragment:?} in {err}"
974            );
975        }
976        // THE EFFECT, not just the verdicts: no refusal left a registration
977        // behind, and the generation never moved.
978        assert_eq!(registry.active_registration_count().unwrap(), 0);
979        assert_eq!(registry.generation().unwrap(), 0);
980    }
981
982    #[test]
983    fn module_id_path_component_length_matches_shared_refusal_vectors() {
984        let doc: serde_json::Value = serde_json::from_str(include_str!(
985            "../tests/golden/module_id_path_component_refusals.json"
986        ))
987        .expect("refusal fixture parses");
988
989        for case in doc["vectors"].as_array().expect("vectors array") {
990            let name = case["name"].as_str().expect("name");
991            let module_id = case["module_id"]["unit"]
992                .as_str()
993                .expect("module_id unit")
994                .repeat(
995                    case["module_id"]["repeat"]
996                        .as_u64()
997                        .expect("module_id repeat") as usize,
998                );
999            assert_eq!(
1000                module_id.len(),
1001                case["utf8_bytes"].as_u64().expect("utf8 bytes") as usize
1002            );
1003
1004            let expected = case["expect_reason"].as_str().map(str::to_owned);
1005            assert_eq!(
1006                module_id_path_hazard(&module_id).err(),
1007                expected,
1008                "shared refusal vector {name:?} diverged"
1009            );
1010        }
1011    }
1012
1013    #[test]
1014    fn working_id_shapes_register_including_namespace_colons() {
1015        let registry = Registry::default();
1016        for (i, good) in ["magic-context", "mcp:everything", "v1.2-module"]
1017            .iter()
1018            .enumerate()
1019        {
1020            registry
1021                .register_with_control_ops(
1022                    manifest(good),
1023                    1,
1024                    ConnectionId::new(10 + i as u64),
1025                    Vec::new(),
1026                )
1027                .unwrap_or_else(|err| panic!("id {good:?} must register: {err}"));
1028        }
1029        assert_eq!(registry.active_registration_count().unwrap(), 3);
1030    }
1031}
1032
1033#[cfg(test)]
1034mod swap_slot_tests {
1035    use super::*;
1036
1037    fn manifest(module_id: &str, ready: Option<bool>) -> ModuleManifest {
1038        let mut manifest = ModuleManifest::builder(module_id, "0.1.0").build();
1039        manifest.ready = ready;
1040        manifest
1041    }
1042
1043    const INCUMBENT: ConnectionId = ConnectionId(1);
1044    const CANDIDATE: ConnectionId = ConnectionId(2);
1045
1046    fn registry_with_candidate() -> Registry {
1047        let registry = Registry::default();
1048        registry
1049            .register_with_control_ops(manifest("m", None), 1, INCUMBENT, Vec::new())
1050            .unwrap();
1051        registry
1052            .register_candidate_with_control_ops(
1053                manifest("m", Some(false)),
1054                1,
1055                CANDIDATE,
1056                Vec::new(),
1057            )
1058            .unwrap();
1059        registry
1060    }
1061
1062    #[test]
1063    fn candidate_is_invisible_to_by_id_lookups_and_listing() {
1064        let registry = registry_with_candidate();
1065        let generation = registry.generation().unwrap();
1066        assert_eq!(
1067            registry.get_module("m").unwrap().unwrap().connection_id,
1068            INCUMBENT
1069        );
1070        let (listed_generation, listed) = registry.list_modules().unwrap();
1071        assert_eq!(listed.len(), 1);
1072        assert_eq!(listed[0].connection_id, INCUMBENT);
1073        assert_eq!(listed_generation, generation);
1074        assert_eq!(registry.active_registration_count().unwrap(), 1);
1075        assert_eq!(
1076            registry.get_candidate("m").unwrap().unwrap().connection_id,
1077            CANDIDATE
1078        );
1079        assert_eq!(
1080            registry
1081                .register_candidate_with_control_ops(
1082                    manifest("m", None),
1083                    1,
1084                    ConnectionId(3),
1085                    Vec::new()
1086                )
1087                .unwrap_err(),
1088            RegistryError::DuplicateModuleId {
1089                module_id: "m".to_string()
1090            }
1091        );
1092    }
1093
1094    /// Without the candidate slot in the connection-keyed search, this update
1095    /// returns `Ok(None)` and the candidate never becomes ready.
1096    #[test]
1097    fn candidate_catalog_update_reaches_the_candidate_registration() {
1098        let registry = registry_with_candidate();
1099        assert!(!registry.get_candidate("m").unwrap().unwrap().ready);
1100
1101        let updated = registry
1102            .replace_catalog_for_connection(CANDIDATE, Vec::new(), None, Some(true))
1103            .unwrap()
1104            .expect("the candidate's own connection finds its registration");
1105
1106        assert_eq!(updated.connection_id, CANDIDATE);
1107        assert!(registry.get_candidate("m").unwrap().unwrap().ready);
1108        assert_eq!(
1109            registry.get_module_by_connection(CANDIDATE).unwrap(),
1110            Some(updated)
1111        );
1112        assert_eq!(
1113            registry.get_module("m").unwrap().unwrap().connection_id,
1114            INCUMBENT,
1115            "a candidate's update must not touch the active registration"
1116        );
1117    }
1118
1119    #[test]
1120    fn promotion_swaps_slots_and_each_connection_still_deregisters_its_own() {
1121        let registry = registry_with_candidate();
1122        let before = registry.generation().unwrap();
1123        let cutover = registry.promote_candidate("m").unwrap().unwrap();
1124        assert_eq!(cutover.promoted.connection_id, CANDIDATE);
1125        assert_eq!(cutover.superseded.unwrap().connection_id, INCUMBENT);
1126        assert_ne!(registry.generation().unwrap(), before);
1127        assert_eq!(registry.promote_candidate("m").unwrap(), None);
1128
1129        assert_eq!(
1130            registry
1131                .registration(RegistrationSlot::Active("m"))
1132                .unwrap()
1133                .unwrap()
1134                .connection_id,
1135            CANDIDATE
1136        );
1137        assert!(registry
1138            .registration(RegistrationSlot::Candidate("m"))
1139            .unwrap()
1140            .is_none());
1141        assert!(registry
1142            .registration(RegistrationSlot::Connection(INCUMBENT))
1143            .unwrap()
1144            .is_some());
1145
1146        let closed = registry.deregister_connection(INCUMBENT).unwrap();
1147        assert_eq!(closed.len(), 1);
1148        assert_eq!(closed[0].connection_id, INCUMBENT);
1149        assert_eq!(closed[0].state, ChannelState::Closed);
1150        assert!(registry
1151            .registration(RegistrationSlot::Connection(INCUMBENT))
1152            .unwrap()
1153            .is_none());
1154        assert_eq!(
1155            registry.get_module("m").unwrap().unwrap().connection_id,
1156            CANDIDATE
1157        );
1158    }
1159
1160    #[test]
1161    fn a_dropped_candidate_deregisters_from_the_candidate_slot_only() {
1162        let registry = registry_with_candidate();
1163        let closed = registry.deregister_connection(CANDIDATE).unwrap();
1164        assert_eq!(closed.len(), 1);
1165        assert_eq!(closed[0].connection_id, CANDIDATE);
1166        assert!(registry.get_candidate("m").unwrap().is_none());
1167        assert_eq!(
1168            registry.get_module("m").unwrap().unwrap().connection_id,
1169            INCUMBENT
1170        );
1171    }
1172}