Skip to main content

protocol_droid/application/registry/
subscriber.rs

1//! Telling the sessions that a list changed — and the sessions that must not
2//! be told.
3//!
4//! # The gate, and why it is the whole point
5//!
6//! `notifications/tools/list_changed` is a capability a server advertises and a
7//! client may or may not have asked about. Sending one to a session that never
8//! advertised [`Feature::ToolsListChanged`] is a message the peer has no
9//! contract for, and a peer that handles unknown notifications strictly — which
10//! is most of them — answers with an error for a notification that has no id,
11//! which cannot be correlated, and which then sits in a queue until the
12//! connection times out.
13//!
14//! So the fan-out is not "tell everyone what changed". It is "tell the
15//! sessions whose negotiated profile already says they want to hear this, and
16//! tell each of them once". Both halves are load-bearing and both have a test.
17//!
18//! # What a subscriber carries, and why the caller supplies the method
19//!
20//! A [`Subscriber`] holds a [`Feature`] and a method name, both supplied by the
21//! caller rather than derived here. The registry's job is "a session that
22//! registered for this feature and this kind gets this frame"; which frame that
23//! is, and which feature gates it, is a fact about the protocol, and the
24//! protocol is not this module's to name. A2A has no `list_changed`, and a
25//! registry that hardcoded the MCP spelling would be a module that could not
26//! serve it.
27//!
28//! # Why one notification per publish, not per session
29//!
30//! Each subscriber is sent at most once per publish, and only if the publish
31//! touched the kind it registered for. A publish that adds a tool and removes a
32//! resource sends a tool notification to tool subscribers and a resource
33//! notification to resource subscribers, and sends neither to anyone twice.
34//!
35//! # Failures are counted, not propagated
36//!
37//! A sink that has failed is counted in [`Fanout::failed`] and does not stop
38//! the remaining subscribers from being told. One dead connection must not
39//! silence the `list_changed` for every other live session — the count is
40//! returned so the caller can drop the subscriber, and the fan-out carries on.
41
42use std::sync::Arc;
43
44use arc_swap::ArcSwap;
45
46use crate::application::session::{DynFrameSink, SinkError};
47use crate::domain::registry::{PrimitiveKind, Snapshot};
48use crate::domain::{Feature, FeatureProfile};
49
50/// One session's standing interest in being told that a list changed.
51///
52/// Built once, when the session registers, and read on every publish after
53/// that. Holding the [`FeatureProfile`] by value rather than by reference is
54/// deliberate: it is a `u128` bitset that `FeatureProfile` derives `Copy` for,
55/// and a subscriber that outlives its session must not borrow one.
56#[derive(Clone)]
57pub struct Subscriber {
58    kind: PrimitiveKind,
59    /// A second kind announced under the same notification.
60    ///
61    /// `resources/list_changed` covers both resources and resource templates,
62    /// and two subscribers on one sink would tell a session twice about a batch
63    /// that touched both — the promise this module makes is once per publish.
64    also: Option<PrimitiveKind>,
65    feature: Feature,
66    method: &'static str,
67    frame: Arc<str>,
68    profile: FeatureProfile,
69    sink: Arc<dyn DynFrameSink>,
70}
71
72impl Subscriber {
73    /// A subscriber for `kind`, gated on `feature`, told with `method`.
74    ///
75    /// `frame` is the encoded notification. It is built once here rather than
76    /// per publish because the bytes are identical for every session receiving
77    /// it, and encoding a notification per subscriber turns one publish into
78    /// one serialisation per session — which is the difference between a
79    /// fan-out that costs a socket write and one that costs a JSON encode.
80    #[must_use]
81    pub fn new(
82        kind: PrimitiveKind,
83        feature: Feature,
84        method: &'static str,
85        frame: &str,
86        profile: FeatureProfile,
87        sink: Arc<dyn DynFrameSink>,
88    ) -> Self {
89        Self {
90            kind,
91            also: None,
92            feature,
93            method,
94            frame: Arc::from(frame),
95            profile,
96            sink,
97        }
98    }
99
100    /// The same subscriber, also told when `kind` changes.
101    ///
102    /// For a notification that names more than one kind — see the field's own
103    /// comment for the one MCP has.
104    #[must_use]
105    pub const fn also_for(mut self, kind: PrimitiveKind) -> Self {
106        self.also = Some(kind);
107        self
108    }
109
110    /// The kind of primitive this subscriber wants to hear about.
111    #[must_use]
112    pub const fn kind(&self) -> PrimitiveKind {
113        self.kind
114    }
115
116    /// Whether a publish that touched `kinds` concerns this subscriber.
117    #[must_use]
118    pub fn covers(&self, kinds: &[PrimitiveKind]) -> bool {
119        kinds
120            .iter()
121            .any(|kind| *kind == self.kind || Some(*kind) == self.also)
122    }
123
124    /// The method this subscriber is notified with.
125    #[must_use]
126    pub const fn method(&self) -> &'static str {
127        self.method
128    }
129
130    /// Whether this subscriber's negotiated profile permits the notification.
131    ///
132    /// The one place the gate is decided, so there is no second caller that
133    /// forgets to ask.
134    #[must_use]
135    pub fn is_eligible(&self) -> bool {
136        self.profile.allows(self.feature)
137    }
138}
139
140impl core::fmt::Debug for Subscriber {
141    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
142        // The sink is a trait object and the frame is a wire document; neither
143        // is worth a multi-line dump in a log line about a registration.
144        f.debug_struct("Subscriber")
145            .field("kind", &self.kind)
146            .field("feature", &self.feature)
147            .field("method", &self.method)
148            .field("profile", &self.profile)
149            .field("eligible", &self.is_eligible())
150            .finish_non_exhaustive()
151    }
152}
153
154/// What one fan-out did.
155#[derive(Debug, Default)]
156pub struct Fanout {
157    /// Subscribers that were told.
158    pub sent: usize,
159    /// Subscribers skipped because their profile did not permit it.
160    pub gated: usize,
161    /// Subscribers whose sink failed. Each is counted, not described: the
162    /// session id is deliberately not carried here, because a report that
163    /// named sessions would be a report that logs them.
164    pub failed: usize,
165}
166
167impl Fanout {
168    /// Whether anyone was actually told.
169    #[must_use]
170    pub const fn is_silent(&self) -> bool {
171        self.sent == 0
172    }
173}
174
175/// The set of sessions currently listening.
176///
177/// Reads are lock-free: the subscriber list is behind an [`ArcSwap`] for the
178/// same reason the primitive set is, and a publish walks a `Vec` of `Arc`s
179/// that no writer can mutate.
180#[derive(Default)]
181pub struct SubscriberSet {
182    entries: ArcSwap<Vec<Arc<Subscriber>>>,
183}
184
185impl SubscriberSet {
186    /// An empty set.
187    #[must_use]
188    pub fn new() -> Self {
189        Self {
190            entries: ArcSwap::from_pointee(Vec::new()),
191        }
192    }
193
194    /// The current subscribers, as one owned snapshot.
195    #[must_use]
196    pub fn all(&self) -> Vec<Arc<Subscriber>> {
197        self.entries.load_full().as_ref().clone()
198    }
199
200    /// How many sessions are listening.
201    #[must_use]
202    pub fn len(&self) -> usize {
203        self.entries.load().len()
204    }
205
206    /// Whether anyone is listening.
207    #[must_use]
208    pub fn is_empty(&self) -> bool {
209        self.entries.load().is_empty()
210    }
211
212    /// Adds a session's standing interest.
213    pub fn add(&self, subscriber: Subscriber) {
214        let mut next: Vec<Arc<Subscriber>> = self.all();
215        next.push(Arc::new(subscriber));
216        self.entries.store(Arc::new(next));
217    }
218
219    /// Drops every subscriber whose sink is `sink`, by identity.
220    ///
221    /// By pointer identity rather than by session id because a session id is
222    /// the thing this module is not allowed to log, and comparing two of them
223    /// to decide who to remove would be a use of one anyway. The sink is the
224    /// connection, and a connection is what a disconnect closes.
225    ///
226    /// Returns how many were dropped, so a caller can assert that a
227    /// disconnect actually unsubscribed rather than assuming it.
228    pub fn remove_by_sink(&self, sink: &Arc<dyn DynFrameSink>) -> usize {
229        let current = self.all();
230        let next: Vec<Arc<Subscriber>> = current
231            .iter()
232            .filter(|entry| !Arc::ptr_eq(&entry.sink, sink))
233            .map(Arc::clone)
234            .collect();
235        let removed = current.len() - next.len();
236        if removed > 0 {
237            self.entries.store(Arc::new(next));
238        }
239        removed
240    }
241
242    /// Tells the eligible subscribers that `kinds` changed in `snapshot`.
243    ///
244    /// A subscriber is told when **both** hold: it registered for a kind this
245    /// publish touched, and its own negotiated profile permits the
246    /// notification. The kind test comes from the publish; the profile test
247    /// comes from the subscriber. Neither can be derived from the other, which
248    /// is why both are here.
249    ///
250    /// The subscriber list is loaded once, so a subscriber that disconnects
251    /// mid-fan-out is either told or not told but never told twice, and a
252    /// subscriber that joins mid-fan-out is not told about a change it could
253    /// not have observed.
254    pub async fn announce(&self, snapshot: &Snapshot, kinds: &[PrimitiveKind]) -> Fanout {
255        let mut report = Fanout::default();
256        if kinds.is_empty() {
257            return report;
258        }
259        let generation = snapshot.generation();
260        for entry in self.all() {
261            if !entry.covers(kinds) {
262                continue;
263            }
264            if !entry.is_eligible() {
265                report.gated += 1;
266                continue;
267            }
268            match entry.sink.send(&entry.frame).await {
269                Ok(()) => report.sent += 1,
270                Err(SinkError) => report.failed += 1,
271            }
272        }
273        // The generation is read so a caller can log which publish this
274        // fan-out belonged to. A fan-out that cannot say which generation it
275        // announced is a fan-out that cannot be reasoned about after the fact.
276        debug_assert!(
277            generation > 0 || snapshot.is_blank(),
278            "a published snapshot is numbered"
279        );
280        report
281    }
282}
283
284impl core::fmt::Debug for SubscriberSet {
285    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
286        f.debug_struct("SubscriberSet")
287            .field("len", &self.len())
288            .finish()
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use std::sync::Arc;
295    use std::sync::atomic::{AtomicUsize, Ordering};
296
297    use super::{Subscriber, SubscriberSet};
298    use crate::application::session::{DynFrameSink, FrameSink, SinkError};
299    use crate::domain::registry::PrimitiveKind;
300    use crate::domain::registry::TestError;
301    use crate::domain::{Feature, FeatureProfile};
302
303    const FRAME: &str = r#"{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}"#;
304
305    #[derive(Default)]
306    struct Counter(AtomicUsize);
307
308    impl FrameSink for Counter {
309        #[expect(
310            clippy::unused_async_trait_impl,
311            reason = "FrameSink is a native async fn trait; the signature is not ours to change"
312        )]
313        async fn send(&self, _frame: &str) -> Result<(), SinkError> {
314            self.0.fetch_add(1, Ordering::Relaxed);
315            Ok(())
316        }
317    }
318
319    fn watch(profile: FeatureProfile, sink: &Arc<Counter>) -> Subscriber {
320        Subscriber::new(
321            PrimitiveKind::Tool,
322            Feature::ToolsListChanged,
323            "notifications/tools/list_changed",
324            FRAME,
325            profile,
326            Arc::clone(sink) as Arc<dyn DynFrameSink>,
327        )
328    }
329
330    fn snapshot() -> crate::domain::registry::Snapshot {
331        crate::domain::registry::Snapshot::empty()
332    }
333
334    /// Eligibility is decided by the subscriber's own profile and nowhere else,
335    /// so a fan-out cannot be written that forgets to ask.
336    #[test]
337    fn eligibility_is_the_negotiated_profile() {
338        let sink = Arc::new(Counter::default());
339        let opted_in = watch(
340            FeatureProfile::empty().with(Feature::ToolsListChanged),
341            &sink,
342        );
343        let opted_out = watch(FeatureProfile::empty().with(Feature::Tools), &sink);
344
345        assert!(opted_in.is_eligible());
346        assert!(!opted_out.is_eligible());
347    }
348
349    /// A subscriber remembers what it was told to send. Nothing recomputes the
350    /// method at send time, because a fan-out that built a notification per
351    /// subscriber would pay a serialisation per session.
352    #[test]
353    fn a_subscriber_carries_its_method() {
354        let sink = Arc::new(Counter::default());
355        let subscriber = watch(
356            FeatureProfile::empty().with(Feature::ToolsListChanged),
357            &sink,
358        );
359        assert_eq!(subscriber.kind(), PrimitiveKind::Tool);
360        assert_eq!(subscriber.method(), "notifications/tools/list_changed");
361    }
362
363    /// Nothing to announce means nothing sent, and the counters say which of
364    /// the three paths it took.
365    #[tokio::test]
366    async fn an_empty_fan_out_reports_silence() {
367        let set = SubscriberSet::new();
368        let report = set.announce(&snapshot(), &[]).await;
369        assert!(report.is_silent());
370        assert_eq!(report.sent, 0);
371        assert_eq!(report.gated, 0);
372        assert_eq!(report.failed, 0);
373    }
374
375    /// A subscriber is told once per announce, and a gated one is counted as
376    /// gated rather than silently skipped.
377    #[tokio::test]
378    async fn a_gated_subscriber_is_counted_not_skipped() {
379        let set = SubscriberSet::new();
380        let sink = Arc::new(Counter::default());
381        set.add(watch(FeatureProfile::empty().with(Feature::Tools), &sink));
382
383        let report = set.announce(&snapshot(), &[PrimitiveKind::Tool]).await;
384        assert_eq!(report.gated, 1);
385        assert_eq!(report.sent, 0);
386        assert_eq!(sink.0.load(Ordering::Relaxed), 0);
387    }
388
389    /// A kind nobody subscribed to reaches nobody.
390    #[tokio::test]
391    async fn an_unwatched_kind_reaches_nobody() {
392        let set = SubscriberSet::new();
393        let sink = Arc::new(Counter::default());
394        set.add(watch(
395            FeatureProfile::empty().with(Feature::ToolsListChanged),
396            &sink,
397        ));
398
399        let report = set.announce(&snapshot(), &[PrimitiveKind::Resource]).await;
400        assert!(report.is_silent());
401        assert_eq!(sink.0.load(Ordering::Relaxed), 0);
402    }
403
404    /// Dropping a subscriber is by sink identity, and reports how many went —
405    /// a disconnect has to be able to assert that it actually unsubscribed.
406    #[tokio::test]
407    async fn a_disconnect_removes_exactly_its_own_subscriber() -> Result<(), TestError> {
408        let set = SubscriberSet::new();
409        let staying = Arc::new(Counter::default());
410        let leaving = Arc::new(Counter::default());
411        let profile = FeatureProfile::empty().with(Feature::ToolsListChanged);
412        set.add(watch(profile, &staying));
413        set.add(watch(profile, &leaving));
414        assert_eq!(set.len(), 2);
415
416        let removed = set.remove_by_sink(&(Arc::clone(&leaving) as Arc<dyn DynFrameSink>));
417        assert_eq!(removed, 1);
418        assert_eq!(set.len(), 1);
419
420        set.announce(&snapshot(), &[PrimitiveKind::Tool]).await;
421        assert_eq!(staying.0.load(Ordering::Relaxed), 1);
422        assert_eq!(leaving.0.load(Ordering::Relaxed), 0);
423        Ok(())
424    }
425
426    /// Removing a sink nobody registered removes nothing, and says so rather
427    /// than reporting a spurious success.
428    #[test]
429    fn removing_an_unknown_sink_removes_nothing() {
430        let set = SubscriberSet::new();
431        let unknown: Arc<dyn DynFrameSink> = Arc::new(Counter::default());
432        assert_eq!(set.remove_by_sink(&unknown), 0);
433        assert!(set.is_empty());
434    }
435}