Skip to main content

zenkey_fleet/bus/
roster.rs

1//! The liveliness roster (RFC 04 §5).
2
3use std::collections::BTreeMap;
4use std::time::Duration;
5
6use crate::report::{Freshness, MediaStreamInfo, NodeInfo, ProducerInfo};
7use crate::{Error, Result};
8use zenkey::grammar::with_base;
9
10/// The fleet-presence roster: who is up, and what they run.
11///
12/// RFC 04 §5 — a liveliness query on `<base>/v1/*/state/*/alive`. Zero
13/// payload bytes: the token *key* is the record. `@catalog` is asked for by
14/// name because `*` can never match a verbatim service origin (property D4).
15pub async fn roster(
16    fleet: &crate::Fleet<'_>,
17    timeout: Duration,
18) -> Result<BTreeMap<String, Vec<String>>> {
19    let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
20
21    let catalog_alive = zenkey::selector::service_alive(&zenkey::ServiceOrigin::catalog());
22
23    // The builders are base-relative; this session is deliberately
24    // un-namespaced, so it must spell the base itself.
25    for expr in [
26        fleet.wire(zenkey::selector::all_liveliness(
27            zenkey::selector::Scope::fleet(),
28        )),
29        fleet.wire(catalog_alive),
30    ] {
31        let Ok(replies) = fleet
32            .session()
33            .liveliness()
34            .get(&expr)
35            .timeout(timeout)
36            .await
37        else {
38            continue;
39        };
40        while let Ok(reply) = replies.recv_async().await {
41            let Ok(sample) = reply.result() else { continue };
42            let key = sample.key_expr().as_str();
43            let Some((origin, producer)) = token_identity(fleet.base(), key) else {
44                continue;
45            };
46            out.entry(origin).or_default().push(producer);
47        }
48    }
49    for producers in out.values_mut() {
50        producers.sort();
51        producers.dedup();
52    }
53    Ok(out)
54}
55
56/// A live roster, driven by liveliness events rather than polled (#56).
57///
58/// The roster is *pushed* by the bus, so a `--watch` on it has no business
59/// running a timer. Both explorers had the same loop — seed with one GET,
60/// subscribe with history, coalesce a burst, re-render only on a real change
61/// — and zenctl's copy had drifted into `cmd/node.rs` alongside a duplicate of
62/// the polling driver's cycle body (issue #207). This is that loop, once.
63///
64/// Zero data-plane subscribers by construction: the monitor is started with an
65/// empty selector list and only liveliness selectors, so watching the roster
66/// costs nothing on the data plane (the lazy contract, #85).
67pub struct RosterWatch {
68    monitor: crate::Monitor,
69    events: crate::EventStream,
70    roster: BTreeMap<String, Vec<String>>,
71    base: String,
72    /// What [`next_change`](RosterWatch::next_change) has applied to `roster`
73    /// but not yet reported — the accumulator, held here rather than in the
74    /// poll's stack frame so a dropped poll cannot take it with it (#328).
75    pending: RosterChange,
76}
77
78/// What one coalesced burst of liveliness events did to the roster.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub struct RosterChange {
81    /// At least one producer appeared. The caller may want to re-read the
82    /// registry — a new producer can serve a slice nothing has asked for yet —
83    /// and this says so once per burst rather than once per event.
84    pub node_up: bool,
85    /// At least one producer went away.
86    pub node_down: bool,
87}
88
89/// How long a burst is drained before rendering. Liveliness storms arrive in
90/// clumps (a host booting declares every producer at once); one frame per
91/// clump beats one frame per token.
92const BURST_QUIET: Duration = Duration::from_millis(50);
93
94impl RosterWatch {
95    /// Subscribe, then seed.
96    ///
97    /// Both, and in that order: the monitor's history-backed events can land
98    /// in the broadcast before this task drains it, so history alone races,
99    /// and a GET alone would miss everything after it. Duplicates from a late
100    /// history event are absorbed by [`apply_token`]'s idempotence.
101    pub async fn start(fleet: &crate::Fleet<'_>, timeout: Duration) -> Result<RosterWatch> {
102        let liveliness = vec![
103            fleet.wire(zenkey::selector::all_liveliness(
104                zenkey::selector::Scope::fleet(),
105            )),
106            fleet.wire(zenkey::selector::service_alive(
107                &zenkey::ServiceOrigin::catalog(),
108            )),
109        ];
110        let monitor = crate::Monitor::start(
111            fleet.session(),
112            crate::MonitorSpec {
113                selectors: vec![],
114                liveliness,
115                ..Default::default()
116            },
117        )
118        .await?;
119        let events = monitor.events();
120        let roster = roster(fleet, timeout).await?;
121        Ok(RosterWatch {
122            monitor,
123            events,
124            roster,
125            base: fleet.base().to_string(),
126            pending: RosterChange::default(),
127        })
128    }
129
130    /// The roster as it stands. Valid immediately after [`start`](Self::start)
131    /// — the first frame is the seed, and "0 producers" is a statement rather
132    /// than silence (RFC 05 §3.1).
133    pub fn roster(&self) -> &BTreeMap<String, Vec<String>> {
134        &self.roster
135    }
136
137    /// Wait for the roster to actually change, coalescing a burst into one
138    /// answer. `None` = the event stream closed.
139    ///
140    /// Never returns for a burst that changed nothing, so a caller can render
141    /// on every `Some` without checking.
142    ///
143    /// **Cancel-safe** (#328): [`apply_token`] mutates `self.roster` the moment
144    /// an event lands, and a burst is drained across an await — so a poll
145    /// dropped in that await had already changed the roster. The accumulator
146    /// therefore lives in `self.pending`, not on the stack: dropping this
147    /// future loses the *wait*, never the change, and the next call reports it
148    /// before it listens for anything further. A roster that moved while its
149    /// caller was told nothing is a hole in the window with no later event
150    /// bound to correct it — RFC 13 §3 O6, where an unreported gap converts
151    /// "I missed it" into "it never happened".
152    pub async fn next_change(&mut self) -> Option<RosterChange> {
153        next_change_in(
154            &mut self.events,
155            &mut self.roster,
156            &self.base,
157            &mut self.pending,
158        )
159        .await
160    }
161
162    /// Release the subscriptions, **acknowledged**.
163    ///
164    /// On every exit path, which the zenctl original managed only on Ctrl-C:
165    /// its channel-closed arm returned before reaching `monitor.stop()`, so a
166    /// closed stream leaked the liveliness subscribers (#207).
167    ///
168    /// And awaited, which it only looked like (#336): this was an `async fn`
169    /// that awaited nothing, calling the `Drop` teardown and leaving the
170    /// liveliness subscribers to undeclare in the background. It now goes
171    /// through [`crate::Monitor::shutdown`], so the caller that waits for this
172    /// gets what waiting was for.
173    pub async fn stop(self) -> Result<()> {
174        self.monitor.shutdown().await
175    }
176}
177
178/// [`RosterWatch::next_change`]'s body over its four moving parts — the seam
179/// that lets the cancellation contract be tested against a bare
180/// [`crate::MonitorCore`], with no session and no bus.
181async fn next_change_in(
182    events: &mut crate::EventStream,
183    roster: &mut BTreeMap<String, Vec<String>>,
184    base: &str,
185    pending: &mut RosterChange,
186) -> Option<RosterChange> {
187    loop {
188        // First, whatever a previous — possibly cancelled — poll applied.
189        if let Some(change) = take_change(pending) {
190            return Some(change);
191        }
192        let mut item = events.recv().await;
193        loop {
194            match item {
195                // Closed: report this burst's work, and say so on the next
196                // call — the roster moved, and a closing stream is no reason
197                // to drop the last thing it said.
198                None => return take_change(pending),
199                Some(crate::StreamItem::Dropped(_)) => {}
200                Some(crate::StreamItem::Event(ev)) => {
201                    let transition = match ev {
202                        crate::FleetEvent::NodeUp(key) => Some((key, true)),
203                        crate::FleetEvent::NodeDown(key) => Some((key, false)),
204                        _ => None,
205                    };
206                    if let Some((key, up)) = transition
207                        && apply_token(roster, base, &key, up)
208                    {
209                        if up {
210                            pending.node_up = true;
211                        } else {
212                            pending.node_down = true;
213                        }
214                    }
215                }
216            }
217            match tokio::time::timeout(BURST_QUIET, events.recv()).await {
218                Ok(next) => item = next,
219                Err(_) => break,
220            }
221        }
222    }
223}
224
225/// Take the accumulated change, leaving nothing behind. `None` when the burst
226/// moved nothing — a caller renders on every `Some` without checking.
227fn take_change(pending: &mut RosterChange) -> Option<RosterChange> {
228    if !pending.node_up && !pending.node_down {
229        return None;
230    }
231    Some(std::mem::take(pending))
232}
233
234/// Who a liveliness token names: `(origin, producer)`, or `None` when the key
235/// is not a token under this base.
236///
237/// One home for a rule that had two (issue #207): `roster()` read it off a GET
238/// reply and `zenctl node list --watch` re-derived it, character for
239/// character, from a `NodeUp`/`NodeDown` event. `@catalog`'s token has no
240/// producer chunk — the service *is* the producer — and everything else names
241/// its producer in position 5.
242pub fn token_identity(base: &str, key: &str) -> Option<(String, String)> {
243    let parsed = zenkey::grammar::parse_full(base, key)?;
244    let origin = parsed.origin.chunk().to_string();
245    let producer = parsed
246        .producer()
247        .map(|p| p.chunk())
248        .unwrap_or_else(|| origin.trim_start_matches('@').to_string());
249    Some((origin, producer))
250}
251
252/// Apply one liveliness transition to a roster. Returns whether it changed
253/// anything — a burst of no-op events must not force a re-render.
254///
255/// Idempotent on the way up, which is what makes a seeded roster safe: the
256/// history-backed events a monitor replays can land after the one-shot GET
257/// that seeded it, and a duplicate `NodeUp` returns `false` rather than
258/// double-listing the producer.
259pub fn apply_token(
260    roster: &mut BTreeMap<String, Vec<String>>,
261    base: &str,
262    key: &str,
263    up: bool,
264) -> bool {
265    let Some((origin, producer)) = token_identity(base, key) else {
266        return false;
267    };
268    if up {
269        let entry = roster.entry(origin).or_default();
270        if entry.contains(&producer) {
271            return false;
272        }
273        entry.push(producer);
274        entry.sort();
275        return true;
276    }
277    let Some(entry) = roster.get_mut(&origin) else {
278        return false;
279    };
280    let before = entry.len();
281    entry.retain(|p| p != &producer);
282    let changed = entry.len() != before;
283    if entry.is_empty() {
284        roster.remove(&origin);
285    }
286    changed
287}
288
289/// Roster → typed rows, joining the slice facts when given (`--verbose`).
290/// Absent slice = `None` fields, never a default (RFC 09 §5.1 O4).
291pub fn node_rows(
292    roster: &BTreeMap<String, Vec<String>>,
293    slices: Option<&crate::SliceSet>,
294) -> crate::report::NodeList {
295    let mut nodes = Vec::new();
296
297    for (origin, producers) in roster {
298        for producer in producers {
299            let joined = slices.and_then(|s| {
300                // Instance suffixes share the base slice (RFC 03 §1.5).
301                let base_name = zenkey::grammar::Producer::parse_chunk(producer)
302                    .map(|pr| pr.name().to_string())
303                    .unwrap_or_else(|_| producer.clone());
304                s.get(&base_name)
305            });
306            nodes.push(crate::report::NodeRow {
307                origin: origin.clone(),
308                producer: producer.clone(),
309                app: joined.map(|s| s.app.clone()),
310                registry_version: joined.map(|s| s.version.clone()),
311            });
312        }
313    }
314    crate::report::NodeList {
315        nodes,
316        slices_joined: slices.is_some(),
317    }
318}
319
320/// How one origin string spells its two framework keys. A host and a service
321/// differ in both (`v1/<h>/state/*/alive` + a producer chunk in `@rpc`, versus
322/// `v1/@svc/state/alive` + no producer chunk), and a `*` can reach neither
323/// other's shape — so the split is made once, up front, rather than guessed
324/// per key (D4).
325enum Node {
326    Host(zenkey::origin::RemoteOrigin),
327    Service(zenkey::ServiceOrigin),
328}
329
330impl Node {
331    fn parse(origin: &str) -> Result<Node> {
332        if origin.starts_with('@') {
333            zenkey::ServiceOrigin::new(origin)
334                .map(Node::Service)
335                .map_err(Error::from)
336        } else {
337            zenkey::origin::RemoteOrigin::parse(origin)
338                .map(Node::Host)
339                .map_err(|e| {
340                    Error::unaskable(
341                        "origin",
342                        format!("{e} — a hostname is not an origin (RFC 06 §6)"),
343                    )
344                })
345        }
346    }
347
348    /// This node's liveliness tokens, and nothing else's.
349    fn alive_selector(&self) -> String {
350        match self {
351            Node::Host(o) => {
352                zenkey::selector::all_liveliness(zenkey::selector::Scope::origin(o)).to_string()
353            }
354            Node::Service(o) => zenkey::selector::service_alive(o).to_string(),
355        }
356    }
357
358    /// This node's producers' `introspect`, and nothing else's.
359    fn introspect_selector(&self) -> String {
360        match self {
361            Node::Host(o) => zenkey::selector::rpc(
362                zenkey::selector::Scope::origin(o),
363                zenkey::selector::Producers::all(),
364                &["introspect"],
365            )
366            .to_string(),
367            Node::Service(o) => zenkey::selector::service_rpc(o, &["introspect"]).to_string(),
368        }
369    }
370
371    /// This node's state subtree. `**` cannot cross an `@` chunk (D2), so this
372    /// cannot pull a plane however deep the subject tail runs.
373    fn state_selector(&self) -> String {
374        let scope = match self {
375            Node::Host(o) => zenkey::selector::Scope::origin(o),
376            Node::Service(o) => zenkey::selector::Scope::origin(o),
377        };
378        zenkey::selector::all_state(scope).to_string()
379    }
380}
381
382/// Assemble one node's full story (issue #40; feeds `zenctl node info` and
383/// the zengui dashboard).
384///
385/// Three bounded sweeps, **all three scoped to the asked origin** (issue #96 —
386/// before it, this re-ran the whole fleet roster and a fleet-wide introspect
387/// fan-in per call and then filtered, which the zengui node dashboard pays for
388/// on every card click): this origin's liveliness tokens, this origin's
389/// producers' introspect replies (per-origin truth, not the fleet-deduped
390/// `SliceSet`), and — when `with_freshness` — one state GET on this origin
391/// only (D2 guarantees it cannot pull planes).
392///
393/// Narrower is also *more* honest: the answers can no longer be diluted by a
394/// deduplication across origins that never applied to this one.
395pub async fn node_info(
396    fleet: &crate::Fleet<'_>,
397    origin: &str,
398    timeout: Duration,
399    with_freshness: bool,
400) -> Result<NodeInfo> {
401    let (session, base) = (fleet.session(), fleet.base());
402
403    let node = Node::parse(origin)?;
404
405    // Liveliness, this origin only. A producer chunk is position 5 for a host;
406
407    // `@catalog`'s token has none — the service *is* the producer.
408    let mut alive: Vec<String> = Vec::new();
409    let alive_expr = with_base(base, node.alive_selector());
410    if let Ok(replies) = session.liveliness().get(&alive_expr).timeout(timeout).await {
411        while let Ok(reply) = replies.recv_async().await {
412            let Ok(sample) = reply.result() else { continue };
413            let Some(parsed) = zenkey::grammar::parse_full(base, sample.key_expr().as_str()) else {
414                continue;
415            };
416            alive.push(
417                parsed
418                    .producer()
419                    .map(|p| p.chunk())
420                    .unwrap_or_else(|| parsed.origin.chunk().trim_start_matches('@').to_string()),
421            );
422        }
423    }
424    alive.sort();
425    alive.dedup();
426
427    // Per-origin capabilities: one origin-scoped introspect GET. Replies are
428    // still attributed by reply key, so a router that answered for somebody
429    // else could not smuggle a slice in.
430    let introspect = with_base(base, node.introspect_selector());
431    let answers = crate::bus::query::fleet_get(
432        fleet,
433        &introspect,
434        &crate::bus::query::GetOpts::new(timeout),
435    )
436    .await
437    .unwrap_or_default();
438    let served: Vec<zenkey::slice::RegistrySlice> = answers
439        .into_iter()
440        .filter(|a| a.origin == origin)
441        .filter_map(|a| {
442            let crate::bus::query::Answer::Value(bytes) = a.answer else {
443                return None;
444            };
445            let toml = String::from_utf8_lossy(&bytes.to_bytes()).to_string();
446            match zenkey::parse_slice(&toml) {
447                Ok(slice) => Some(slice),
448                Err(e) => {
449                    tracing::warn!(origin, "introspect reply did not parse, skipping: {e}");
450                    None
451                }
452            }
453        })
454        .collect();
455    let mine: Vec<&zenkey::slice::RegistrySlice> = served.iter().collect();
456
457    let mut names: Vec<String> = alive.clone();
458    names.extend(mine.iter().map(|s| s.name.clone()));
459    names.sort();
460    names.dedup();
461
462    let producers: Vec<ProducerInfo> = names
463        .iter()
464        .map(|name| {
465            let slice = mine.iter().find(|s| &s.name == name);
466            ProducerInfo {
467                name: name.clone(),
468                alive: alive.iter().any(|a| a == name),
469                app: slice.map(|s| s.app.clone()),
470                registry_version: slice.map(|s| s.version.clone()),
471                subjects: slice.map(|s| s.subjects.len()).unwrap_or(0),
472                procedures: slice.map(|s| s.procedures.len()).unwrap_or(0),
473                blob_tiers: slice
474                    .map(|s| s.blob.iter().map(|b| b.tier.token().to_string()).collect())
475                    .unwrap_or_default(),
476                media: slice
477                    .map(|s| {
478                        s.media
479                            .iter()
480                            .map(|m| MediaStreamInfo {
481                                path: m.path.clone(),
482                                encoding: m.encoding.as_encoding_str().to_string(),
483                            })
484                            .collect()
485                    })
486                    .unwrap_or_default(),
487                deprecated_served: slice.map(|s| s.deprecated.len()).unwrap_or(0),
488            }
489        })
490        .collect();
491
492    let mut freshness = Vec::new();
493    if with_freshness && !mine.is_empty() {
494        // One origin-scoped state sweep; join against declared ttl_s.
495        let selector = with_base(base, node.state_selector());
496        let samples = crate::bus::query::state_snapshot(session, &selector, timeout, None)
497            .await
498            .unwrap_or_default();
499        let now = std::time::SystemTime::now();
500        for slice in &mine {
501            for subject in &slice.subjects {
502                let Some(ttl) = subject.ttl_s else { continue };
503                if !subject.class.is(&zenkey::Class::State) {
504                    continue;
505                }
506                // Newest sample whose tail refines to this subject.
507                let age = samples
508                    .iter()
509                    .filter_map(|s| {
510                        let parsed = zenkey::grammar::parse_full(base, &s.key)?;
511                        let p = parsed.producer()?.name().to_string();
512                        if p != slice.name {
513                            return None;
514                        }
515                        let tail: Vec<&str> = parsed.subject.clone();
516                        let pattern = zenkey::pattern::SubjectPattern::parse(&subject.path).ok()?;
517                        pattern.matches(&tail)?;
518                        s.timestamp.map(|t| {
519                            now.duration_since(t.get_time().to_system_time())
520                                .map(|d| d.as_secs() as i64)
521                                .unwrap_or(0)
522                        })
523                    })
524                    .min();
525                freshness.push(Freshness {
526                    producer: slice.name.clone(),
527                    path: subject.path.clone(),
528                    ttl_s: ttl,
529                    age_s: age,
530                    stale: match age {
531                        Some(a) => a > ttl,
532                        // Declared live state with no sample anywhere: stale
533                        // in the sense that matters — but the age stays None.
534                        None => true,
535                    },
536                });
537            }
538        }
539    }
540
541    Ok(NodeInfo {
542        origin: origin.to_string(),
543        producers,
544        freshness,
545    })
546}
547
548/// One origin claiming a human identity label, through the health-document
549/// bridge (RFC 06 §6.2 bridge 1).
550#[derive(Debug, Clone, PartialEq, Eq)]
551pub struct BridgeMatch {
552    /// The origin id — the payload `host_id`, which IS the origin (§6.1).
553    pub host_id: zenkey::origin::HostId,
554    /// The display label the document carried (`source`).
555    pub source: String,
556    /// The key the claim arrived on — self-certifying, because the doc is
557    /// origin-scoped and carries `host_id` beside `source`.
558    pub key: String,
559}
560
561/// Resolve a human identity (hostname, `source` label) to the origin(s)
562/// claiming it — the consumer identity bridge, run the sanctioned way
563/// (RFC 06 §6.2): GET the fleet's `state/<producer>/health` documents and
564/// read `host_id` beside `source`. Every match is returned; the *caller*
565/// prices zero (the bridge yielded nothing — a probe MUST fail there,
566/// RFC 09 §6) and more-than-one (a hostname collision is exactly the
567/// misrouting hazard §6.2 names).
568///
569/// A document without both fields is skipped silently here — it is not a
570/// claim about this label either way — but the total documents seen ride
571/// back so the caller can tell "no claims" from "nobody answered".
572pub async fn bridge_resolve(
573    fleet: &crate::Fleet<'_>,
574    producer: &str,
575    label: &str,
576    timeout: std::time::Duration,
577) -> Result<(Vec<BridgeMatch>, usize)> {
578    let relative =
579        zenkey::selector::producer_state(zenkey::selector::Scope::fleet(), producer, &["health"])
580            .to_string();
581    let key = fleet.wire(relative);
582    let answers =
583        crate::bus::query::fleet_get(fleet, &key, &crate::bus::query::GetOpts::new(timeout))
584            .await?;
585    let mut matches = Vec::new();
586    let seen = answers.len();
587    for a in &answers {
588        let crate::bus::query::Answer::Value(bytes) = &a.answer else {
589            continue;
590        };
591        let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&bytes.to_bytes()) else {
592            continue;
593        };
594        let (Some(host_id), Some(source)) = (
595            doc.get("host_id").and_then(|v| v.as_str()),
596            doc.get("source").and_then(|v| v.as_str()),
597        ) else {
598            continue;
599        };
600        if source == label
601            && let Ok(id) = zenkey::origin::HostId::parse(host_id)
602        {
603            matches.push(BridgeMatch {
604                host_id: id,
605                source: source.to_string(),
606                key: a.key.clone(),
607            });
608        }
609    }
610    matches.dedup_by(|a, b| a.host_id == b.host_id);
611    Ok((matches, seen))
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    /// The rule that had two homes (#207): `roster()` read it off a GET reply
619    /// and the CLI's watch loop re-derived it from a liveliness event.
620    #[test]
621    fn a_token_names_its_origin_and_producer() {
622        assert_eq!(
623            token_identity("acme", "acme/v1/h-3fa9c2d41b7e/state/sysinfo/alive"),
624            Some(("h-3fa9c2d41b7e".into(), "sysinfo".into()))
625        );
626        // A service origin's token carries no producer chunk — the service is
627        // the producer (RFC 06 §5).
628        assert_eq!(
629            token_identity("acme", "acme/v1/@catalog/state/alive"),
630            Some(("@catalog".into(), "catalog".into()))
631        );
632        // The base-less deployment is a real one (RFC v1.6).
633        assert_eq!(
634            token_identity("", "v1/h-3fa9c2d41b7e/state/sysinfo/alive"),
635            Some(("h-3fa9c2d41b7e".into(), "sysinfo".into()))
636        );
637        assert_eq!(token_identity("acme", "demo/not/a/token"), None);
638    }
639
640    /// Idempotent up, subtractive down — what makes seeding safe. The monitor
641    /// replays history-backed tokens that the seeding GET may already have
642    /// returned, and a double `NodeUp` must not double-list the producer or
643    /// force a redundant frame.
644    #[test]
645    fn applying_a_token_reports_only_real_changes() {
646        let mut roster: BTreeMap<String, Vec<String>> = BTreeMap::new();
647        let key = "acme/v1/h-3fa9c2d41b7e/state/sysinfo/alive";
648
649        assert!(apply_token(&mut roster, "acme", key, true));
650        assert!(
651            !apply_token(&mut roster, "acme", key, true),
652            "a replayed history token is not a change"
653        );
654        assert_eq!(roster["h-3fa9c2d41b7e"], ["sysinfo"]);
655
656        // A second producer on the same origin sorts in.
657        let other = "acme/v1/h-3fa9c2d41b7e/state/alerts/alive";
658        assert!(apply_token(&mut roster, "acme", other, true));
659        assert_eq!(roster["h-3fa9c2d41b7e"], ["alerts", "sysinfo"]);
660
661        assert!(apply_token(&mut roster, "acme", key, false));
662        assert!(
663            !apply_token(&mut roster, "acme", key, false),
664            "retracting what is already gone is not a change"
665        );
666        assert_eq!(roster["h-3fa9c2d41b7e"], ["alerts"]);
667
668        // The last producer leaving takes the origin with it: an origin with
669        // no producers is not a fact worth rendering.
670        assert!(apply_token(&mut roster, "acme", other, false));
671        assert!(roster.is_empty());
672
673        // An unparseable key changes nothing and does not panic (O1).
674        assert!(!apply_token(&mut roster, "acme", "demo/foreign", true));
675    }
676
677    /// The join is `None`-on-absence, never a default (O4), and an instance
678    /// suffix shares its base slice (RFC 03 §1.5).
679    #[test]
680    fn rows_say_whether_a_slice_was_even_asked_for() {
681        let mut roster: BTreeMap<String, Vec<String>> = BTreeMap::new();
682        roster.insert(
683            "h-3fa9c2d41b7e".into(),
684            vec!["sysinfo".into(), "sysinfo-2".into()],
685        );
686
687        let unasked = node_rows(&roster, None);
688        assert!(!unasked.slices_joined, "no join was attempted");
689        assert!(unasked.nodes.iter().all(|n| n.app.is_none()));
690
691        let slice = zenkey::parse_slice(
692            "[registry]\nversion = \"1.0\"\napp = \"demo\"\nconvention = 1\n\
693             [producer]\nname = \"sysinfo\"\n",
694        )
695        .expect("fixture slice parses");
696        let joined = node_rows(&roster, Some(&crate::SliceSet::from_slices(vec![slice])));
697        assert!(joined.slices_joined);
698        assert_eq!(joined.nodes.len(), 2);
699        for row in &joined.nodes {
700            assert_eq!(
701                row.app.as_deref(),
702                Some("demo"),
703                "an instance suffix shares the base producer's slice: {}",
704                row.producer
705            );
706        }
707        assert_eq!(
708            joined.nodes[1].producer, "sysinfo-2",
709            "the row keeps the suffix"
710        );
711    }
712
713    /// The cancellation contract (#328): a poll dropped mid-burst has already
714    /// mutated the roster, so the change it accumulated must survive the drop.
715    /// It used to live in the poll's stack frame and die with it — the roster
716    /// moved, the caller was told nothing, and the display stayed stale until
717    /// some unrelated later token happened to arrive.
718    ///
719    /// Time is paused, so the two windows below are exact rather than raced:
720    /// the token is ready immediately, and the poll is then dropped inside
721    /// [`BURST_QUIET`] while it waits for the rest of the burst.
722    #[tokio::test(start_paused = true)]
723    async fn a_cancelled_poll_keeps_the_change_it_already_applied() {
724        let core = crate::MonitorCore::new(16);
725        let mut events = core.events();
726        let mut roster: BTreeMap<String, Vec<String>> = BTreeMap::new();
727        let mut pending = RosterChange::default();
728
729        core.node_event("v1/h-3fa9c2d41b7e/state/sysinfo/alive".into(), true);
730
731        let cancelled = tokio::time::timeout(
732            BURST_QUIET / 2,
733            next_change_in(&mut events, &mut roster, "", &mut pending),
734        )
735        .await;
736        assert!(cancelled.is_err(), "the poll is still draining the burst");
737        assert!(
738            roster.contains_key("h-3fa9c2d41b7e"),
739            "the token was applied before the drop"
740        );
741
742        // …and the next call reports it, without waiting on the bus for a
743        // second event that may never come.
744        let change = tokio::time::timeout(
745            BURST_QUIET / 2,
746            next_change_in(&mut events, &mut roster, "", &mut pending),
747        )
748        .await
749        .expect("the applied change is reported, not waited on")
750        .expect("a change, not a closed stream");
751        assert_eq!(
752            change,
753            RosterChange {
754                node_up: true,
755                node_down: false
756            }
757        );
758    }
759}