Skip to main content

zenkey_fleet/
sub.rs

1//! The live monitor (issue #15): subscription multiplexing + liveliness
2//! watching, fanned into a bounded broadcast of [`FleetEvent`]s, with the
3//! key-tree snapshot published on a stats tick.
4//!
5//! The zengui contract, concretely:
6//! - per-sample events feed **only** sample-shaped consumers (echo panes) —
7//!   the channel is bounded, and overflow surfaces as an explicit
8//!   [`StreamItem::Dropped`] count on the lagging receiver, never silently;
9//! - tree/dashboard consumers redraw on [`FleetEvent::StatsTick`] by
10//!   *pulling* the immutable [`KeyTreeSnapshot`] from an `ArcSwap` — a hot
11//!   bus cannot melt a render loop.
12
13use std::sync::Arc;
14use std::sync::Mutex;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::time::{Duration, Instant};
17
18use anyhow::{Result, anyhow};
19use arc_swap::ArcSwap;
20use tokio::sync::broadcast;
21use zenoh::Session;
22use zenoh::sample::SampleKind;
23
24use crate::stats::StatsTable;
25use crate::tree::KeyTreeSnapshot;
26
27/// The publisher a sample came from, when its session attaches SourceInfo
28/// — the same signal the gap counter reads, surfaced (#120). All `Copy`:
29/// carrying it costs no allocation.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct SampleSource {
32    /// The publishing session's Zenoh id.
33    pub zid: zenoh::config::ZenohId,
34    /// The entity id within that session.
35    pub eid: u32,
36    /// The per-entity sequence number — what the gap counter diffs.
37    pub sn: u32,
38}
39
40/// One observed sample, cheap to clone (the payload is zenoh's refcounted
41/// buffer, not a copy — report §14's zero-copy discipline).
42#[derive(Debug, Clone)]
43pub struct SampleView {
44    /// Full wire key, as received (this session is un-namespaced).
45    pub key: String,
46    pub payload: zenoh::bytes::ZBytes,
47    /// The sample's declared encoding, verbatim.
48    pub encoding: String,
49    pub kind: SampleKind,
50    /// HLC timestamp when the publisher's session stamps one.
51    ///
52    /// Absent is common — a publisher whose session is not timestamping stamps
53    /// nothing — and absence must never be defaulted to an arrival time. This
54    /// is the *publisher's* clock; [`SampleView::received`] is ours, and the
55    /// two are never mixed (a consumer plotting a time axis states which one
56    /// it plotted).
57    pub timestamp: Option<zenoh::time::Timestamp>,
58    /// The sample's attachment, when it carried one — zenoh's refcounted
59    /// buffer, like the payload, so retaining it is a refcount bump and the
60    /// per-sample allocation floor stands (`docs/zero-copy.md` §4).
61    ///
62    /// `None` means the sample carried none: an attachment is a wire fact,
63    /// not a decode, and what arrived is a fact to show (#117).
64    pub attachment: Option<zenoh::bytes::ZBytes>,
65    /// The wire's actual QoS axes (#120) — always present: zenoh stamps
66    /// every sample with them, defaults included. A registry *declares* a
67    /// profile; these are what actually rode, and the two can disagree —
68    /// which is exactly what a frontend renders. All `Copy`.
69    ///
70    /// Deliberately absent: SHM-vs-raw buffer provenance. zenoh 1.9's
71    /// public API does not expose it on a received sample, and chasing it
72    /// through `zenoh::internal` is the dependency this crate refuses
73    /// (nuze's decoder is the cautionary tale).
74    pub priority: zenoh::qos::Priority,
75    pub congestion_control: zenoh::qos::CongestionControl,
76    pub reliability: zenoh::qos::Reliability,
77    pub express: bool,
78    /// The publishing entity, when SourceInfo rode the sample. `None` is
79    /// "the publisher's session does not attach it" — common, and not a
80    /// defect.
81    pub source: Option<SampleSource>,
82    /// Arrival, on **this observer's** monotonic clock — always available,
83    /// never wall-clock, and never a claim about when the sample was produced.
84    ///
85    /// Stamped per sample rather than per batch so a consumer that coalesces
86    /// (zengui ticks at 250 ms) can still space a 5 Hz key's samples truthfully
87    /// instead of collapsing a tick's worth onto one instant.
88    pub received: Instant,
89}
90
91impl SampleView {
92    /// Whether the wire's actual axes match a declared profile (RFC 04 §3)
93    /// — the declared-vs-observed comparison nobody else in the field can
94    /// render, because nobody else holds a registry that declares QoS.
95    pub fn qos_matches(&self, profile: zenkey::qos::QosProfile) -> bool {
96        self.priority == profile.priority()
97            && self.congestion_control == profile.congestion_control()
98            && self.reliability == profile.reliability()
99            && self.express == profile.express()
100    }
101}
102
103/// What the monitor emits.
104///
105/// Deliberately **no matching variant** (#38/#80 adoption note): zenoh 1.9
106/// has matching listeners on publishers and queriers only — a subscriber
107/// cannot ask "does anyone publish what I watch", so the monitor's watches
108/// have nothing honest to report here. Matching lives on the write facade's
109/// [`crate::Publication`] and on [`crate::RepeatingQuery`], the two entities
110/// this process declares that zenoh can answer for.
111#[derive(Debug, Clone)]
112pub enum FleetEvent {
113    Sample(Arc<SampleView>),
114    /// A liveliness token appeared (full wire key of the token).
115    NodeUp(String),
116    /// A liveliness token disappeared.
117    NodeDown(String),
118    /// The tree snapshot was rebuilt — pull it via [`Monitor::tree`].
119    StatsTick,
120    /// The watch set changed ([`Monitor::watch`]/[`Monitor::unwatch`]) —
121    /// coverage labels should refresh; pull the set via [`Monitor::watched`].
122    WatchChanged,
123    /// A seeded watch's seed phase resolved (issue #92): both seed paths of
124    /// [`Monitor::watch_seeded`] finished, with what each contributed.
125    /// Everything on this watch after this event is live-only — "seeding"
126    /// panes flip to "live" here, never on a guess.
127    WatchSeeded {
128        id: WatchId,
129        coverage: crate::seed::SeedCoverage,
130    },
131}
132
133/// What to watch.
134#[derive(Debug, Clone)]
135pub struct MonitorSpec {
136    /// Full wire selectors to subscribe to.
137    pub selectors: Vec<String>,
138    /// Also watch these liveliness selectors (with history: current tokens
139    /// arrive on join — no separate seed GET).
140    ///
141    /// A list, not a single selector, because one selector cannot express the
142    /// roster: `*` in the origin position never matches a verbatim service
143    /// origin (RFC 03 §4 **D4**), so the fleet sweep
144    /// `<base>/v1/*/state/*/alive` and `<base>/v1/@catalog/state/alive` are
145    /// necessarily two entries. A dashboard that watches only the first
146    /// renders "catalog dead" and "no entities" identically — the false
147    /// verdict RFC 05 §3.1 forbids.
148    pub liveliness: Vec<String>,
149    /// Snapshot cadence.
150    pub stats_tick: Duration,
151    /// Broadcast capacity: bound it to what an echo pane can drain; lag is
152    /// surfaced, never hidden.
153    pub capacity: usize,
154    /// How many distinct keys to keep statistics for. Least-recently-seen keys
155    /// are dropped past this, and the drops are counted
156    /// ([`MonitorCore::keys_evicted`]) — a long-running observer is bounded,
157    /// and says so (RFC 09 §5.1).
158    pub max_keys: usize,
159}
160
161impl Default for MonitorSpec {
162    fn default() -> Self {
163        MonitorSpec {
164            selectors: Vec::new(),
165            liveliness: Vec::new(),
166            stats_tick: Duration::from_millis(250),
167            capacity: 1024,
168            max_keys: crate::stats::DEFAULT_MAX_KEYS,
169        }
170    }
171}
172
173/// The monitor's shareable core: ingest on one side, events + snapshots on
174/// the other. Session wiring lives in [`Monitor`]; the core is pure and
175/// deterministically testable.
176pub struct MonitorCore {
177    tx: broadcast::Sender<FleetEvent>,
178    stats: Mutex<StatsTable>,
179    tree: ArcSwap<KeyTreeSnapshot>,
180    dropped: AtomicU64,
181}
182
183impl MonitorCore {
184    pub fn new(capacity: usize) -> Arc<MonitorCore> {
185        MonitorCore::bounded(capacity, crate::stats::DEFAULT_MAX_KEYS)
186    }
187
188    /// A core whose statistics table is bounded at `max_keys` distinct keys.
189    pub fn bounded(capacity: usize, max_keys: usize) -> Arc<MonitorCore> {
190        let (tx, _) = broadcast::channel(capacity.max(2));
191        Arc::new(MonitorCore {
192            tx,
193            stats: Mutex::new(StatsTable::with_capacity(max_keys)),
194            tree: ArcSwap::from_pointee(KeyTreeSnapshot::default()),
195            dropped: AtomicU64::new(0),
196        })
197    }
198
199    /// Ingest one sample: stats update + broadcast. Hot path — one lock, no
200    /// tree work (that happens on the tick).
201    pub fn ingest(&self, view: SampleView, sn: Option<u32>) {
202        {
203            // Observed *skewed* latency (#119): our wall clock minus the
204            // publisher's HLC — both halves this crate deliberately never
205            // mixes elsewhere, subtracted here on purpose and labeled as
206            // containing clock skew. Unstamped samples pass None and are
207            // counted, not defaulted (no latency ≠ zero latency).
208            let latency_us = view.timestamp.map(|t| {
209                let published = t.get_time().to_system_time();
210                match std::time::SystemTime::now().duration_since(published) {
211                    Ok(d) => i64::try_from(d.as_micros()).unwrap_or(i64::MAX),
212                    // The publisher's clock is ahead of ours: negative, and
213                    // shown as such — that *is* the skew evidence.
214                    Err(e) => -i64::try_from(e.duration().as_micros()).unwrap_or(i64::MAX),
215                }
216            });
217            let mut stats = self.stats.lock().expect("stats lock");
218            stats.record(
219                &view.key,
220                view.payload.len(),
221                sn,
222                Instant::now(),
223                latency_us,
224            );
225        }
226        // Send errors mean "no receiver right now" — not a failure.
227        let _ = self.tx.send(FleetEvent::Sample(Arc::new(view)));
228    }
229
230    pub fn node_event(&self, key: String, up: bool) {
231        let _ = self.tx.send(if up {
232            FleetEvent::NodeUp(key)
233        } else {
234            FleetEvent::NodeDown(key)
235        });
236    }
237
238    /// Rebuild the snapshot from the stats and announce it.
239    pub fn tick(&self) {
240        let snapshot = {
241            let stats = self.stats.lock().expect("stats lock");
242            KeyTreeSnapshot::build(&stats)
243        };
244        self.tree.store(Arc::new(snapshot));
245        let _ = self.tx.send(FleetEvent::StatsTick);
246    }
247
248    /// The latest immutable snapshot (lock-free pull).
249    pub fn tree(&self) -> Arc<KeyTreeSnapshot> {
250        self.tree.load_full()
251    }
252
253    /// Read access to the raw stats (hz/bw commands).
254    pub fn with_stats<R>(&self, f: impl FnOnce(&StatsTable) -> R) -> R {
255        f(&self.stats.lock().expect("stats lock"))
256    }
257
258    /// Mutable access — watch retirement and tests.
259    pub fn with_stats_mut<R>(&self, f: impl FnOnce(&mut StatsTable) -> R) -> R {
260        f(&mut self.stats.lock().expect("stats lock"))
261    }
262
263    /// Keys retired from the table because their watch was released
264    /// (RFC 09 §5.1 O6 — see [`crate::stats::StatsTable::unwatched`]).
265    pub fn keys_unwatched(&self) -> u64 {
266        self.with_stats(|s| s.unwatched())
267    }
268
269    /// Total events dropped across all lagging receivers so far.
270    pub fn dropped(&self) -> u64 {
271        self.dropped.load(Ordering::Relaxed)
272    }
273
274    /// Distinct keys dropped from the statistics table to stay within its
275    /// bound. Non-zero means the key set on display is partial — report it
276    /// rather than letting a shrinking tree read as a quieting bus
277    /// (RFC 09 §5.1).
278    pub fn keys_evicted(&self) -> u64 {
279        self.with_stats(|s| s.evicted())
280    }
281
282    /// Subscribe to the event stream.
283    pub fn events(self: &Arc<Self>) -> EventStream {
284        EventStream {
285            rx: self.tx.subscribe(),
286            core: Arc::clone(self),
287        }
288    }
289}
290
291/// A receiver that surfaces lag as data: when this consumer falls behind the
292/// bounded channel, the next `recv` yields the count of samples it missed —
293/// dropped samples are never invisible (RFC 05 §3.1's honesty, applied to a
294/// UI).
295pub struct EventStream {
296    rx: broadcast::Receiver<FleetEvent>,
297    core: Arc<MonitorCore>,
298}
299
300/// An event, or how many this receiver just missed.
301#[derive(Debug, Clone)]
302pub enum StreamItem {
303    Event(FleetEvent),
304    Dropped(u64),
305}
306
307impl EventStream {
308    /// `None` when the monitor stopped.
309    pub async fn recv(&mut self) -> Option<StreamItem> {
310        match self.rx.recv().await {
311            Ok(ev) => Some(StreamItem::Event(ev)),
312            Err(broadcast::error::RecvError::Lagged(n)) => {
313                self.core.dropped.fetch_add(n, Ordering::Relaxed);
314                Some(StreamItem::Dropped(n))
315            }
316            Err(broadcast::error::RecvError::Closed) => None,
317        }
318    }
319}
320
321/// Opaque handle naming one active watch.
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
323pub struct WatchId(u64);
324
325struct WatchEntry {
326    selector: String,
327    subscriber: zenoh::pubsub::Subscriber<()>,
328    /// The seed task, while a seeded watch's seed phase is still running.
329    /// Aborted on [`Monitor::unwatch`] so a released watch cannot keep
330    /// ingesting seed replies. (A dropped *monitor* lets it run out — it is
331    /// bounded by the seed timeout and feeds a core nobody reads.)
332    seed_task: Option<tokio::task::JoinHandle<()>>,
333}
334
335/// The wired monitor: a runtime-mutable watch set + liveliness + tick task
336/// feeding a core.
337///
338/// **Lazy by construction** (issue #84): `start` with empty
339/// `spec.selectors` declares *no data-plane subscribers at all* — only the
340/// zero-payload liveliness watches and the tick. Data flows only for what
341/// [`Monitor::watch`] was asked to observe, and [`Monitor::unwatch`]
342/// provably undeclares (an explicit, awaited undeclaration — not a dropped
343/// handle racing the network).
344pub struct Monitor {
345    core: Arc<MonitorCore>,
346    session: Session,
347    watches: tokio::sync::Mutex<std::collections::HashMap<WatchId, WatchEntry>>,
348    next_watch: AtomicU64,
349    tasks: Vec<tokio::task::JoinHandle<()>>,
350}
351
352impl std::fmt::Debug for Monitor {
353    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354        f.debug_struct("Monitor").finish_non_exhaustive()
355    }
356}
357
358impl Monitor {
359    /// Declare the spec's subscribers on `session` and start watching.
360    /// `spec.selectors` are simply the *initial* watches — `[]` is the lazy
361    /// start.
362    pub async fn start(session: &Session, spec: MonitorSpec) -> Result<Monitor> {
363        let core = MonitorCore::bounded(spec.capacity, spec.max_keys);
364        let mut tasks = Vec::new();
365
366        for liveliness_sel in &spec.liveliness {
367            let subscriber = session
368                .liveliness()
369                .declare_subscriber(liveliness_sel)
370                .history(true)
371                .await
372                .map_err(|e| anyhow!("liveliness subscribe {liveliness_sel}: {e}"))?;
373            let core = Arc::clone(&core);
374            tasks.push(tokio::spawn(async move {
375                while let Ok(sample) = subscriber.recv_async().await {
376                    let key = sample.key_expr().as_str().to_string();
377                    core.node_event(key, sample.kind() == SampleKind::Put);
378                }
379            }));
380        }
381
382        {
383            let core = Arc::clone(&core);
384            let period = spec.stats_tick;
385            tasks.push(tokio::spawn(async move {
386                let mut interval = tokio::time::interval(period);
387                interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
388                loop {
389                    interval.tick().await;
390                    core.tick();
391                }
392            }));
393        }
394
395        let monitor = Monitor {
396            core,
397            session: session.clone(),
398            watches: tokio::sync::Mutex::new(std::collections::HashMap::new()),
399            next_watch: AtomicU64::new(0),
400            tasks,
401        };
402        for selector in &spec.selectors {
403            monitor.watch(selector).await?;
404        }
405        Ok(monitor)
406    }
407
408    /// Observe a selector: declares a callback subscriber feeding the core.
409    ///
410    /// The callback runs on zenoh's network thread and does exactly what the
411    /// old per-selector task did — one stats lock, one bounded broadcast
412    /// send — so a slow UI still cannot exert backpressure into the network
413    /// layer beyond the channel's bound.
414    pub async fn watch(&self, selector: &str) -> Result<WatchId> {
415        let core = Arc::clone(&self.core);
416        let subscriber = self
417            .session
418            .declare_subscriber(selector)
419            .callback(move |sample| {
420                let source = sample.source_info().map(|si| SampleSource {
421                    zid: si.source_id().zid(),
422                    eid: si.source_id().eid(),
423                    sn: si.source_sn(),
424                });
425                core.ingest(
426                    SampleView {
427                        key: sample.key_expr().as_str().to_string(),
428                        payload: sample.payload().clone(),
429                        encoding: sample.encoding().to_string(),
430                        kind: sample.kind(),
431                        timestamp: sample.timestamp().copied(),
432                        attachment: sample.attachment().cloned(),
433                        priority: sample.priority(),
434                        congestion_control: sample.congestion_control(),
435                        reliability: sample.reliability(),
436                        express: sample.express(),
437                        source,
438                        received: Instant::now(),
439                    },
440                    source.map(|s| s.sn),
441                );
442            })
443            .await
444            .map_err(|e| anyhow!("subscribe {selector}: {e}"))?;
445        let id = WatchId(self.next_watch.fetch_add(1, Ordering::Relaxed));
446        self.watches.lock().await.insert(
447            id,
448            WatchEntry {
449                selector: selector.to_string(),
450                subscriber,
451                seed_task: None,
452            },
453        );
454        let _ = self.core.tx.send(FleetEvent::WatchChanged);
455        Ok(id)
456    }
457
458    /// Observe a selector **with a correct seed phase** (issue #92; the
459    /// RFC 04 §3.2 discipline of [`crate::seed_subscribe`], run through this
460    /// monitor's bounded broadcast):
461    ///
462    /// - the subscriber is declared first, then both seed paths run as
463    ///   bounded GETs (`@adv` caches for live publishers, the selector
464    ///   itself for router storages — the crashed-producer case);
465    /// - one per-key LWW merge spans seed *and* live samples until the
466    ///   boundary, so a transition in the seed window lands exactly once and
467    ///   a stale seed cannot regress a key. Suppressions are counted in the
468    ///   coverage, never silently absorbed (O6) — and they are *not* part of
469    ///   `Dropped(n)`, which counts only broadcast lag;
470    /// - [`FleetEvent::WatchSeeded`] fires once **both** paths resolve,
471    ///   carrying this id and the [`crate::SeedCoverage`]. After it, the
472    ///   merge is dropped and live samples flow untouched (the merge map is
473    ///   a seed-phase structure, not a per-watch leak).
474    pub async fn watch_seeded(
475        &self,
476        selector: &str,
477        policy: crate::seed::SeedPolicy,
478    ) -> Result<WatchId> {
479        use crate::seed::{Merge, SeedCoverage, cache_selector, seed_get, view_of};
480
481        // The merge gate: `Some` while seeding (both live callback and seed
482        // replies pass `admit`), swapped to `None` at the boundary.
483        let gate: Arc<arc_swap::ArcSwapOption<Merge>> =
484            Arc::new(arc_swap::ArcSwapOption::from_pointee(Merge::new()));
485
486        // 1) The subscriber, FIRST (RFC 04 §3.2).
487        let core = Arc::clone(&self.core);
488        let cb_gate = Arc::clone(&gate);
489        let subscriber = self
490            .session
491            .declare_subscriber(selector)
492            .callback(move |sample| {
493                let sn = sample.source_info().map(|si| si.source_sn());
494                let view = view_of(&sample);
495                if let Some(merge) = cb_gate.load_full()
496                    && !merge.admit(&view)
497                {
498                    return;
499                }
500                core.ingest(view, sn);
501            })
502            .await
503            .map_err(|e| anyhow!("seeded subscribe {selector}: {e}"))?;
504
505        // 2) The seed GETs, AFTER — registered as this watch's seed task so
506        //    `unwatch` during the seed phase aborts it.
507        let id = WatchId(self.next_watch.fetch_add(1, Ordering::Relaxed));
508        let seed_task = {
509            let session = self.session.clone();
510            let core = Arc::clone(&self.core);
511            let selector = selector.to_string();
512            tokio::spawn(async move {
513                let merge = gate
514                    .load_full()
515                    .expect("gate holds the merge while seeding");
516                let history = async {
517                    if policy.history {
518                        let sel = cache_selector(&selector);
519                        Some(
520                            seed_get(&session, &sel, policy.timeout, &merge, |view| {
521                                core.ingest(view, None);
522                            })
523                            .await,
524                        )
525                    } else {
526                        None
527                    }
528                };
529                let storage = async {
530                    if policy.storage {
531                        Some(
532                            seed_get(&session, &selector, policy.timeout, &merge, |view| {
533                                core.ingest(view, None);
534                            })
535                            .await,
536                        )
537                    } else {
538                        None
539                    }
540                };
541                let (history_replies, storage_replies) = tokio::join!(history, storage);
542                let coverage = SeedCoverage {
543                    history_replies,
544                    storage_replies,
545                    superseded: merge.superseded(),
546                };
547                gate.store(None);
548                // Seeded keys should be visible on the very tick that
549                // announces the boundary, not one tick later.
550                core.tick();
551                let _ = core.tx.send(FleetEvent::WatchSeeded { id, coverage });
552            })
553        };
554        self.watches.lock().await.insert(
555            id,
556            WatchEntry {
557                selector: selector.to_string(),
558                subscriber,
559                seed_task: Some(seed_task),
560            },
561        );
562        let _ = self.core.tx.send(FleetEvent::WatchChanged);
563        Ok(id)
564    }
565
566    /// Stop observing: undeclares the subscriber (awaited to completion — the
567    /// teardown is acknowledged, not racing a drop), then retires statistics
568    /// for keys no remaining watch covers. Retired keys are **counted**
569    /// ([`crate::stats::StatsTable::unwatched`]): a shrinking key set must
570    /// never read as a quieting bus (RFC 09 §5.1 O6).
571    pub async fn unwatch(&self, id: WatchId) -> Result<()> {
572        let mut entry = {
573            let mut watches = self.watches.lock().await;
574            watches
575                .remove(&id)
576                .ok_or_else(|| anyhow!("unknown watch id {id:?}"))?
577        };
578        // A released watch must not keep ingesting seed replies: the seed
579        // task dies with the watch (its boundary event simply never fires —
580        // the watch is gone, so there is nothing left to flip to "live").
581        if let Some(task) = entry.seed_task.take() {
582            task.abort();
583        }
584        entry
585            .subscriber
586            .undeclare()
587            .await
588            .map_err(|e| anyhow!("undeclare {}: {e}", entry.selector))?;
589        let kept: Vec<String> = {
590            let watches = self.watches.lock().await;
591            watches.values().map(|w| w.selector.clone()).collect()
592        };
593        self.core.with_stats_mut(|stats| {
594            stats.retire_unwatched(&entry.selector, &kept);
595        });
596        self.core.tick();
597        let _ = self.core.tx.send(FleetEvent::WatchChanged);
598        Ok(())
599    }
600
601    /// The active watch set.
602    pub async fn watched(&self) -> Vec<(WatchId, String)> {
603        let watches = self.watches.lock().await;
604        let mut v: Vec<(WatchId, String)> = watches
605            .iter()
606            .map(|(id, w)| (*id, w.selector.clone()))
607            .collect();
608        v.sort();
609        v
610    }
611
612    pub fn core(&self) -> &Arc<MonitorCore> {
613        &self.core
614    }
615
616    pub fn events(&self) -> EventStream {
617        self.core.events()
618    }
619
620    pub fn tree(&self) -> Arc<KeyTreeSnapshot> {
621        self.core.tree()
622    }
623
624    /// Stop watching. Equivalent to dropping the monitor — kept as an explicit
625    /// verb for call sites that want to say so.
626    pub fn stop(self) {
627        drop(self);
628    }
629}
630
631/// Dropping a monitor stops it: the ingest tasks are aborted and the
632/// subscribers undeclare.
633///
634/// This is not a nicety. A `JoinHandle` merely *detaches* on drop, so without
635/// this impl every monitor that goes out of scope leaks a live subscriber and
636/// its ingest task for the lifetime of the session. `zenctl` never noticed —
637/// it calls [`Monitor::stop`] once and exits — but a GUI re-scopes its
638/// subscription whenever the user changes what they are watching, dropping and
639/// rebuilding the monitor each time.
640impl Drop for Monitor {
641    fn drop(&mut self) {
642        for t in &self.tasks {
643            t.abort();
644        }
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    fn view(key: &str, len: usize) -> SampleView {
653        SampleView {
654            key: key.to_string(),
655            payload: zenoh::bytes::ZBytes::from(vec![0u8; len]),
656            encoding: "zenoh/bytes".to_string(),
657            kind: SampleKind::Put,
658            timestamp: None,
659            attachment: None,
660            priority: zenoh::qos::Priority::DEFAULT,
661            congestion_control: zenoh::qos::CongestionControl::DEFAULT,
662            reliability: zenoh::qos::Reliability::DEFAULT,
663            express: false,
664            source: None,
665            received: Instant::now(),
666        }
667    }
668
669    #[tokio::test]
670    async fn events_flow_and_snapshots_rebuild_on_tick() {
671        let core = MonitorCore::new(8);
672        let mut events = core.events();
673        core.ingest(view("zs/v1/h-a/telemetry/x/m", 4), None);
674        core.tick();
675
676        let Some(StreamItem::Event(FleetEvent::Sample(s))) = events.recv().await else {
677            panic!("expected sample");
678        };
679        assert_eq!(s.key, "zs/v1/h-a/telemetry/x/m");
680        assert_eq!(s.payload.len(), 4);
681        let Some(StreamItem::Event(FleetEvent::StatsTick)) = events.recv().await else {
682            panic!("expected tick");
683        };
684        let snap = core.tree();
685        assert_eq!(snap.keys, 1);
686        assert_eq!(snap.root.subtree_count, 1);
687    }
688
689    /// The bounded-channel honesty contract: a lagging receiver is told how
690    /// many it missed — never a silent gap.
691    #[tokio::test]
692    async fn overflow_surfaces_as_dropped_counts() {
693        let core = MonitorCore::new(2);
694        let mut slow = core.events();
695        for i in 0..10 {
696            core.ingest(view(&format!("zs/v1/h-a/telemetry/x/m{i}"), 1), None);
697        }
698        let Some(StreamItem::Dropped(n)) = slow.recv().await else {
699            panic!("expected a dropped count first");
700        };
701        assert!(n >= 8, "missed at least 8, reported {n}");
702        assert_eq!(core.dropped(), n);
703        // The stream then resumes with the retained tail.
704        let Some(StreamItem::Event(FleetEvent::Sample(_))) = slow.recv().await else {
705            panic!("expected a sample after the gap report");
706        };
707    }
708}