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