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}