1use std::collections::BTreeMap;
27use std::convert::Infallible;
28use std::sync::atomic::{AtomicU16, AtomicUsize, Ordering};
29use std::sync::Arc;
30use std::time::Duration;
31
32use axum::body::Bytes;
33use futures_util::Stream;
34use serde::Serialize;
35use tokio::sync::watch;
36
37use crate::backend::agent_state::AgentStatus;
38
39pub const CHANGE_POLL_INTERVAL: Duration = Duration::from_secs(1);
41pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
44pub const MAX_STREAMS: usize = 32;
47const RETRY_PREAMBLE: &[u8] = b"retry: 3000\n\n";
49const HEARTBEAT: &[u8] = b": hb\n\n";
50
51pub type AgentKind = &'static str;
54
55#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct FleetSnapshot {
61 pub rev: u64,
62 pub agents: Arc<Vec<String>>,
63 pub agent_kinds: Arc<BTreeMap<String, AgentKind>>,
64 pub channels_running: u32,
66 pub agent_status: Arc<BTreeMap<String, AgentStatus>>,
67 pub agent_lines: Arc<BTreeMap<String, String>>,
69 pub viewer_port: Option<u16>,
72}
73
74#[derive(Debug, Clone, Default)]
78pub struct FleetObservation {
79 pub agents: Vec<(String, Option<AgentKind>)>,
80 pub channels_running: u32,
81 pub agent_status: BTreeMap<String, AgentStatus>,
82 pub agent_lines: BTreeMap<String, String>,
83}
84
85#[derive(Serialize)]
87struct FleetBody<'a> {
88 epoch: &'a str,
89 rev: u64,
90 hostname: &'a str,
91 channel: &'a str,
92 version: &'a str,
93 agents: &'a [String],
94 os: &'a str,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 install_id: Option<&'a str>,
97 channels_running: u32,
98 agent_kinds: &'a BTreeMap<String, AgentKind>,
99 now_ms: u64,
100 agent_status: &'a BTreeMap<String, AgentStatus>,
101 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
102 agent_lines: &'a BTreeMap<String, String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 viewer_port: Option<u16>,
105}
106
107pub struct FleetFeed {
108 epoch: String,
112 hostname: String,
113 channel: String,
114 version: String,
115 os: String,
116 install_id: Option<String>,
118 tx: watch::Sender<FleetSnapshot>,
119 viewer_port: Arc<AtomicU16>,
121 streams: Arc<AtomicUsize>,
122 heartbeat: Duration,
123}
124
125#[cfg(test)]
127pub fn normalize_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
128 normalize_agents(names.into_iter().map(|n| (n, None))).0
129}
130
131fn normalize_agents(
136 agents: impl IntoIterator<Item = (String, Option<AgentKind>)>,
137) -> (Vec<String>, BTreeMap<String, AgentKind>) {
138 let mut keyed: Vec<(String, String, Option<AgentKind>)> = agents
139 .into_iter()
140 .map(|(n, kind)| (n.to_lowercase(), n, kind))
141 .collect();
142 keyed.sort();
143 keyed.dedup_by(|later, earlier| later.0 == earlier.0);
144 let kinds = keyed
145 .iter()
146 .filter_map(|(_, n, kind)| kind.map(|k| (n.clone(), k)))
147 .collect();
148 (keyed.into_iter().map(|(_, n, _)| n).collect(), kinds)
149}
150
151impl FleetFeed {
152 pub fn new(hostname: String, channel: String, version: String) -> Self {
153 let epoch = uuid::Uuid::new_v4().simple().to_string()[..16].to_string();
154 let (tx, _) = watch::channel(FleetSnapshot {
155 rev: 1,
156 agents: Arc::new(Vec::new()),
157 agent_kinds: Arc::new(BTreeMap::new()),
158 channels_running: 1,
159 agent_status: Arc::new(BTreeMap::new()),
160 agent_lines: Arc::new(BTreeMap::new()),
161 viewer_port: None,
162 });
163 Self {
164 epoch,
165 hostname,
166 channel,
167 version,
168 os: crate::backend::host_os::local_os(),
169 install_id: None,
170 tx,
171 viewer_port: Arc::new(AtomicU16::new(0)),
172 streams: Arc::new(AtomicUsize::new(0)),
173 heartbeat: HEARTBEAT_INTERVAL,
174 }
175 }
176
177 pub fn with_viewer_port(mut self, port: Arc<AtomicU16>) -> Self {
179 self.viewer_port = port;
180 self
181 }
182
183 pub fn with_install_id(mut self, install_id: Option<String>) -> Self {
185 self.install_id = install_id;
186 self
187 }
188
189 #[cfg(test)]
190 pub fn epoch(&self) -> &str {
191 &self.epoch
192 }
193
194 pub fn hostname(&self) -> &str {
195 &self.hostname
196 }
197
198 pub fn channel(&self) -> &str {
199 &self.channel
200 }
201
202 pub fn version(&self) -> &str {
203 &self.version
204 }
205
206 pub fn os(&self) -> &str {
207 &self.os
208 }
209
210 pub fn snapshot(&self) -> FleetSnapshot {
211 self.tx.borrow().clone()
212 }
213
214 pub fn subscribe(&self) -> watch::Receiver<FleetSnapshot> {
216 self.tx.subscribe()
217 }
218
219 pub fn event_id(&self, snapshot: &FleetSnapshot) -> String {
221 format!("{}:{}", self.epoch, snapshot.rev)
222 }
223
224 pub fn body_json(&self, snapshot: &FleetSnapshot) -> String {
229 self.body_json_at(snapshot, agentmux_common::time::now_ms_u64())
230 }
231
232 fn body_json_at(&self, snapshot: &FleetSnapshot, now_ms: u64) -> String {
233 serde_json::to_string(&FleetBody {
234 epoch: &self.epoch,
235 rev: snapshot.rev,
236 hostname: &self.hostname,
237 channel: &self.channel,
238 version: &self.version,
239 agents: &snapshot.agents,
240 os: &self.os,
241 install_id: self.install_id.as_deref(),
242 channels_running: snapshot.channels_running,
243 agent_kinds: &snapshot.agent_kinds,
244 now_ms,
245 agent_status: &snapshot.agent_status,
246 agent_lines: &snapshot.agent_lines,
247 viewer_port: snapshot.viewer_port,
248 })
249 .unwrap_or_default()
250 }
251
252 pub fn observe_fleet(&self, observation: FleetObservation) -> bool {
256 let (names, kinds) = normalize_agents(observation.agents);
257 let channels_running = observation.channels_running.clamp(1, 99);
258 let mut status = observation.agent_status;
259 status.retain(|name, _| names.contains(name));
260 let mut lines = observation.agent_lines;
261 lines.retain(|name, _| names.contains(name));
262 let viewer_port = Some(self.viewer_port.load(Ordering::Relaxed)).filter(|p| *p != 0);
263 self.tx.send_if_modified(|current| {
264 if *current.agents == names
265 && *current.agent_kinds == kinds
266 && current.channels_running == channels_running
267 && *current.agent_status == status
268 && *current.agent_lines == lines
269 && current.viewer_port == viewer_port
270 {
271 return false;
272 }
273 current.rev += 1;
274 current.agents = Arc::new(names);
275 current.agent_kinds = Arc::new(kinds);
276 current.channels_running = channels_running;
277 current.agent_status = Arc::new(status);
278 current.agent_lines = Arc::new(lines);
279 current.viewer_port = viewer_port;
280 true
281 })
282 }
283
284 #[cfg(test)]
287 pub fn observe(&self, names: impl IntoIterator<Item = String>) -> bool {
288 let channels_running = self.snapshot().channels_running;
289 self.observe_fleet(FleetObservation {
290 agents: names.into_iter().map(|n| (n, None)).collect(),
291 channels_running,
292 agent_status: BTreeMap::new(),
293 agent_lines: BTreeMap::new(),
294 })
295 }
296
297 pub fn spawn_change_detection<F>(
302 self: Arc<Self>,
303 observe: F,
304 token: tokio_util::sync::CancellationToken,
305 ) where
306 F: Fn() -> FleetObservation + Send + Sync + 'static,
307 {
308 let observe = Arc::new(observe);
309 self.observe_fleet(observe());
310 tokio::spawn(async move {
311 let mut tick = tokio::time::interval(CHANGE_POLL_INTERVAL);
312 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
313 loop {
314 tokio::select! {
315 _ = tick.tick() => {
316 let observe = Arc::clone(&observe);
317 if let Ok(observation) = tokio::task::spawn_blocking(move || observe()).await {
318 self.observe_fleet(observation);
319 }
320 }
321 _ = token.cancelled() => break,
322 }
323 }
324 });
325 }
326
327 pub fn open_stream(
337 self: &Arc<Self>,
338 last_event_id: Option<&str>,
339 ) -> Option<impl Stream<Item = Result<Bytes, Infallible>> + Send + 'static> {
340 let slot = StreamSlot::acquire(&self.streams)?;
341 let feed = Arc::clone(self);
342 let mut rx = self.tx.subscribe();
343 let resume_from = last_event_id.map(str::to_string);
344 Some(async_stream::stream! {
345 let _slot = slot;
346 yield Ok(Bytes::from_static(RETRY_PREAMBLE));
347 let current = rx.borrow_and_update().clone();
348 if resume_from.as_deref() != Some(feed.event_id(¤t).as_str()) {
349 yield Ok(feed.event_bytes(¤t));
350 }
351 let mut heartbeat = tokio::time::interval_at(tokio::time::Instant::now() + feed.heartbeat, feed.heartbeat);
352 heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
353 loop {
354 tokio::select! {
355 changed = rx.changed() => {
356 if changed.is_err() {
357 break;
358 }
359 let snapshot = rx.borrow_and_update().clone();
360 yield Ok(feed.event_bytes(&snapshot));
361 }
362 _ = heartbeat.tick() => {
363 yield Ok(Bytes::from_static(HEARTBEAT));
364 }
365 }
366 }
367 })
368 }
369
370 fn event_bytes(&self, snapshot: &FleetSnapshot) -> Bytes {
373 Bytes::from(format!(
374 "event: fleet\nid: {}\ndata: {}\n\n",
375 self.event_id(snapshot),
376 self.body_json(snapshot)
377 ))
378 }
379
380 #[cfg(test)]
381 pub fn open_streams(&self) -> usize {
382 self.streams.load(Ordering::SeqCst)
383 }
384
385 #[cfg(test)]
386 pub fn with_heartbeat(mut self, every: Duration) -> Self {
387 self.heartbeat = every;
388 self
389 }
390}
391
392struct StreamSlot(Arc<AtomicUsize>);
394
395impl StreamSlot {
396 fn acquire(counter: &Arc<AtomicUsize>) -> Option<Self> {
397 counter
398 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |n| {
399 (n < MAX_STREAMS).then_some(n + 1)
400 })
401 .ok()
402 .map(|_| Self(Arc::clone(counter)))
403 }
404}
405
406impl Drop for StreamSlot {
407 fn drop(&mut self) {
408 self.0.fetch_sub(1, Ordering::SeqCst);
409 }
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415 use crate::backend::agent_state::AgentState;
416 use futures_util::StreamExt;
417
418 fn feed() -> Arc<FleetFeed> {
419 Arc::new(FleetFeed::new(
420 "narko".into(),
421 "stable".into(),
422 "0.59.7".into(),
423 ))
424 }
425
426 fn names(list: &[&str]) -> Vec<String> {
427 list.iter().map(|s| s.to_string()).collect()
428 }
429
430 async fn next_chunk(
431 stream: &mut (impl Stream<Item = Result<Bytes, Infallible>> + Unpin),
432 ) -> String {
433 let chunk = tokio::time::timeout(Duration::from_secs(5), stream.next())
434 .await
435 .expect("a chunk within 5s")
436 .expect("stream open")
437 .unwrap();
438 String::from_utf8(chunk.to_vec()).unwrap()
439 }
440
441 #[test]
442 fn names_are_sorted_case_insensitively_and_deduplicated() {
443 assert_eq!(
444 normalize_names(names(&["clamk", "AgentY", "agenty", "Bravo", "alpha"])),
445 names(&["AgentY", "alpha", "Bravo", "clamk"])
446 );
447 assert_eq!(
449 normalize_names(names(&["agenty", "AgentY"])),
450 normalize_names(names(&["AgentY", "agenty"]))
451 );
452 }
453
454 #[test]
455 fn the_epoch_is_sixteen_lowercase_hex_chars_and_new_per_feed() {
456 let a = feed();
457 assert_eq!(a.epoch().len(), 16);
458 assert!(a
459 .epoch()
460 .chars()
461 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
462 assert_ne!(a.epoch(), feed().epoch());
463 }
464
465 #[test]
466 fn rev_starts_at_one_and_moves_only_when_the_name_set_changes() {
467 let f = feed();
468 assert_eq!(f.snapshot().rev, 1);
469 assert!(f.observe(names(&["b", "a"])));
470 assert_eq!(f.snapshot().rev, 2);
471 assert!(!f.observe(names(&["a", "b"])), "same set, other order");
472 assert!(
473 !f.observe(names(&["a", "a", "b"])),
474 "a duplicate is not a change"
475 );
476 assert_eq!(f.snapshot().rev, 2);
477 assert!(f.observe(names(&["a"])));
478 assert_eq!(f.snapshot().rev, 3);
479 assert_eq!(*f.snapshot().agents, names(&["a"]));
480 }
481
482 fn keys_of(body: &str) -> Vec<String> {
483 let v: serde_json::Value = serde_json::from_str(body).unwrap();
484 let mut keys: Vec<_> = v.as_object().unwrap().keys().cloned().collect();
485 keys.sort();
486 keys
487 }
488
489 fn observation(agents: &[(&str, Option<AgentKind>)], channels_running: u32) -> FleetObservation {
490 FleetObservation {
491 agents: agents.iter().map(|(n, k)| (n.to_string(), *k)).collect(),
492 channels_running,
493 ..Default::default()
494 }
495 }
496
497 fn status(state: AgentState, since_ms: u64) -> AgentStatus {
498 AgentStatus { state, since_ms }
499 }
500
501 fn with_status(agents: &[&str], status: &[(&str, AgentStatus)]) -> FleetObservation {
502 FleetObservation {
503 agents: agents.iter().map(|n| (n.to_string(), Some("host"))).collect(),
504 channels_running: 1,
505 agent_status: status.iter().map(|(n, s)| (n.to_string(), *s)).collect(),
506 ..Default::default()
507 }
508 }
509
510 #[test]
511 fn the_body_has_exactly_the_contract_fields() {
512 let f = feed();
513 f.observe_fleet(observation(&[("Clamk", Some("container")), ("AgentY", Some("host"))], 3));
514 let snap = f.snapshot();
515 let body = f.body_json(&snap);
516 assert!(!body.contains('\n'));
517 let v: serde_json::Value = serde_json::from_str(&body).unwrap();
518 let mut want = names(&[
519 "epoch",
520 "rev",
521 "hostname",
522 "channel",
523 "version",
524 "agents",
525 "os",
526 "channels_running",
527 "agent_kinds",
528 "now_ms",
529 "agent_status",
530 ]);
531 want.sort();
532 assert_eq!(keys_of(&body), want, "no install_id without a WAN identity");
533 assert_eq!(v["epoch"], f.epoch());
534 assert_eq!(v["rev"], 2);
535 assert_eq!(v["hostname"], "narko");
536 assert_eq!(v["channel"], "stable");
537 assert_eq!(v["version"], "0.59.7");
538 assert_eq!(v["agents"], serde_json::json!(["AgentY", "Clamk"]));
539 assert_eq!(v["os"], crate::backend::host_os::local_os());
540 assert_eq!(v["channels_running"], 3);
541 assert_eq!(v["agent_kinds"], serde_json::json!({"AgentY": "host", "Clamk": "container"}));
542 assert_eq!(v["agent_status"], serde_json::json!({}));
543 assert!(v["now_ms"].as_u64().unwrap() > 1_700_000_000_000, "this machine's clock, in ms");
544 }
545
546 #[test]
547 fn agent_status_carries_each_known_state_and_leaves_the_rest_out() {
548 let f = feed();
549 f.observe_fleet(with_status(
550 &["AgentY", "Camper", "Clamk"],
551 &[
552 ("AgentY", status(AgentState::Working, 1_000)),
553 ("Camper", status(AgentState::Waiting, 2_000)),
554 ("Gone", status(AgentState::Idle, 3_000)),
555 ],
556 ));
557 let v: serde_json::Value = serde_json::from_str(&f.body_json_at(&f.snapshot(), 5_000)).unwrap();
558 assert_eq!(v["now_ms"], 5_000);
559 assert_eq!(
560 v["agent_status"],
561 serde_json::json!({
562 "AgentY": {"state": "working", "since_ms": 1_000},
563 "Camper": {"state": "waiting", "since_ms": 2_000},
564 }),
565 "Clamk has no state; Gone is not listed"
566 );
567 }
568
569 #[test]
570 fn agent_lines_are_carried_for_listed_agents_and_a_change_bumps_rev() {
571 let f = feed();
572 let with_lines = |lines: &[(&str, &str)]| FleetObservation {
573 agent_lines: lines.iter().map(|(n, l)| (n.to_string(), l.to_string())).collect(),
574 ..with_status(&["AgentY", "Camper"], &[])
575 };
576 assert!(f.observe_fleet(with_lines(&[("AgentY", "Fixing the login race"), ("Gone", "x")])));
577 let v: serde_json::Value = serde_json::from_str(&f.body_json(&f.snapshot())).unwrap();
578 assert_eq!(v["agent_lines"], serde_json::json!({"AgentY": "Fixing the login race"}), "Gone is not listed");
579 let rev = f.snapshot().rev;
580 assert!(f.observe_fleet(with_lines(&[("AgentY", "Writing the tests")])), "a new line alone is a change");
581 assert_eq!(f.snapshot().rev, rev + 1);
582 assert!(f.observe_fleet(with_lines(&[])));
583 let v: serde_json::Value = serde_json::from_str(&f.body_json(&f.snapshot())).unwrap();
584 assert!(v.get("agent_lines").is_none(), "no lines, no field: {v}");
585 }
586
587 #[test]
588 fn a_state_change_bumps_rev() {
589 let f = feed();
590 assert!(f.observe_fleet(with_status(&["AgentY"], &[("AgentY", status(AgentState::Idle, 1))])));
591 assert_eq!(f.snapshot().rev, 2);
592 assert!(
593 !f.observe_fleet(with_status(&["AgentY"], &[("AgentY", status(AgentState::Idle, 1))])),
594 "same state, same start"
595 );
596 assert!(f.observe_fleet(with_status(&["AgentY"], &[("AgentY", status(AgentState::Working, 9))])));
597 assert_eq!(f.snapshot().rev, 3);
598 assert!(f.observe_fleet(with_status(&["AgentY"], &[])), "state no longer known");
599 assert_eq!(f.snapshot().rev, 4);
600 assert!(f.snapshot().agent_status.is_empty());
601 }
602
603 #[test]
604 fn the_install_id_is_reported_when_known() {
605 let f = FleetFeed::new("narko".into(), "stable".into(), "0.59.7".into())
606 .with_install_id(Some("mw3am46w5weex4a4fqrc3avnua".into()));
607 let body = f.body_json(&f.snapshot());
608 assert!(keys_of(&body).contains(&"install_id".to_string()));
609 let v: serde_json::Value = serde_json::from_str(&body).unwrap();
610 assert_eq!(v["install_id"], "mw3am46w5weex4a4fqrc3avnua");
611 }
612
613 #[test]
614 fn a_kind_or_channel_count_change_bumps_rev_like_a_name_change() {
615 let f = feed();
616 assert!(f.observe_fleet(observation(&[("AgentX", Some("host"))], 1)));
617 assert_eq!(f.snapshot().rev, 2);
618 assert!(!f.observe_fleet(observation(&[("AgentX", Some("host"))], 1)), "nothing changed");
619 assert!(f.observe_fleet(observation(&[("AgentX", Some("container"))], 1)), "kind changed");
620 assert_eq!(f.snapshot().rev, 3);
621 assert!(f.observe_fleet(observation(&[("AgentX", Some("container"))], 3)), "a channel started");
622 assert_eq!(f.snapshot().rev, 4);
623 assert!(f.observe_fleet(observation(&[("AgentX", None)], 3)), "kind no longer known");
624 assert_eq!(f.snapshot().rev, 5);
625 assert!(f.snapshot().agent_kinds.is_empty());
626 }
627
628 #[test]
629 fn the_viewer_port_is_reported_while_up_and_its_change_bumps_rev() {
630 let port = Arc::new(AtomicU16::new(0));
631 let f = FleetFeed::new("h".into(), "c".into(), "v".into()).with_viewer_port(port.clone());
632 assert!(f.observe_fleet(observation(&[("AgentX", None)], 1)));
633 assert!(!keys_of(&f.body_json(&f.snapshot())).contains(&"viewer_port".to_string()));
634 port.store(29702, Ordering::Relaxed);
635 assert!(f.observe_fleet(observation(&[("AgentX", None)], 1)), "the port came up");
636 let v: serde_json::Value = serde_json::from_str(&f.body_json(&f.snapshot())).unwrap();
637 assert_eq!(v["viewer_port"], 29702);
638 assert!(!f.observe_fleet(observation(&[("AgentX", None)], 1)));
639 port.store(0, Ordering::Relaxed);
640 assert!(f.observe_fleet(observation(&[("AgentX", None)], 1)), "and went down");
641 assert_eq!(f.snapshot().viewer_port, None);
642 }
643
644 #[test]
645 fn kinds_follow_the_name_that_survives_deduplication_and_counts_are_clamped() {
646 let f = feed();
647 f.observe_fleet(observation(
648 &[("agenty", Some("container")), ("AgentY", Some("host")), ("Clamk", None)],
649 500,
650 ));
651 let snap = f.snapshot();
652 assert_eq!(*snap.agents, names(&["AgentY", "Clamk"]));
653 assert_eq!(
654 *snap.agent_kinds,
655 BTreeMap::from([("AgentY".to_string(), "host")]),
656 "the unknown kind is left out, not guessed"
657 );
658 assert_eq!(snap.channels_running, 99);
659 f.observe_fleet(observation(&[], 0));
660 assert_eq!(f.snapshot().channels_running, 1);
661 }
662
663 #[tokio::test]
664 async fn a_stream_starts_with_retry_then_the_current_state() {
665 let f = feed();
666 f.observe(names(&["AgentY"]));
667 let mut s = Box::pin(f.open_stream(None).unwrap());
668 assert_eq!(next_chunk(&mut s).await, "retry: 3000\n\n");
669 let event = next_chunk(&mut s).await;
670 let head = format!("event: fleet\nid: {}:2\ndata: ", f.epoch());
671 let data = event
672 .strip_prefix(&head)
673 .and_then(|rest| rest.strip_suffix("\n\n"))
674 .unwrap_or_else(|| panic!("{event}"));
675 let mut got: serde_json::Value = serde_json::from_str(data).unwrap();
676 let now_ms = got["now_ms"].as_u64().expect("now_ms");
677 got["now_ms"] = serde_json::json!(0);
678 let want: serde_json::Value = serde_json::from_str(&f.body_json_at(&f.snapshot(), 0)).unwrap();
679 assert_eq!(got, want);
680 assert!(now_ms > 0);
681 }
682
683 #[tokio::test]
684 async fn a_client_already_up_to_date_gets_no_initial_event_but_does_get_changes() {
685 let f = Arc::new(
686 FleetFeed::new("narko".into(), "stable".into(), "0.59.7".into())
687 .with_heartbeat(Duration::from_secs(3600)),
688 );
689 f.observe(names(&["AgentY"]));
690 let current = f.event_id(&f.snapshot());
691 let mut s = Box::pin(f.open_stream(Some(¤t)).unwrap());
692 assert_eq!(next_chunk(&mut s).await, "retry: 3000\n\n");
693 assert!(tokio::time::timeout(Duration::from_millis(200), s.next())
695 .await
696 .is_err());
697
698 f.observe(names(&["AgentY", "Clamk"]));
699 let event = next_chunk(&mut s).await;
700 assert!(
701 event.starts_with(&format!("event: fleet\nid: {}:3\n", f.epoch())),
702 "{event}"
703 );
704 assert!(event.contains(r#""agents":["AgentY","Clamk"]"#), "{event}");
705 }
706
707 #[tokio::test]
708 async fn a_stale_or_foreign_last_event_id_gets_the_snapshot() {
709 let f = feed();
710 for last in ["0000000000000000:1", "garbage", ""] {
711 let mut s = Box::pin(f.open_stream(Some(last)).unwrap());
712 next_chunk(&mut s).await;
713 assert!(
714 next_chunk(&mut s).await.starts_with("event: fleet\n"),
715 "{last}"
716 );
717 }
718 }
719
720 #[tokio::test]
721 async fn an_idle_stream_sends_heartbeats() {
722 let f = Arc::new(
723 FleetFeed::new("h".into(), "c".into(), "v".into())
724 .with_heartbeat(Duration::from_millis(50)),
725 );
726 let mut s = Box::pin(f.open_stream(None).unwrap());
727 next_chunk(&mut s).await;
728 next_chunk(&mut s).await;
729 assert_eq!(next_chunk(&mut s).await, ": hb\n\n");
730 }
731
732 #[test]
733 fn streams_are_capped_and_a_dropped_stream_frees_its_slot() {
734 let f = feed();
735 let open: Vec<_> = (0..MAX_STREAMS)
736 .map(|_| f.open_stream(None).expect("under the cap"))
737 .collect();
738 assert_eq!(f.open_streams(), MAX_STREAMS);
739 assert!(f.open_stream(None).is_none(), "the 33rd is refused");
740 drop(open);
741 assert_eq!(f.open_streams(), 0);
742 assert!(f.open_stream(None).is_some());
743 }
744
745 #[tokio::test]
746 async fn change_detection_publishes_and_stops_on_cancel() {
747 let f = feed();
748 let source = Arc::new(parking_lot::Mutex::new(names(&["a"])));
749 let token = tokio_util::sync::CancellationToken::new();
750 let read = source.clone();
751 f.clone().spawn_change_detection(
752 move || FleetObservation {
753 agents: read.lock().iter().map(|n| (n.clone(), None)).collect(),
754 channels_running: 1,
755 ..Default::default()
756 },
757 token.clone(),
758 );
759 assert_eq!(
760 *f.snapshot().agents,
761 names(&["a"]),
762 "primed before the first tick"
763 );
764
765 let mut rx = f.tx.subscribe();
766 *source.lock() = names(&["a", "b"]);
767 tokio::time::timeout(Duration::from_secs(5), rx.changed())
768 .await
769 .unwrap()
770 .unwrap();
771 assert_eq!(*f.snapshot().agents, names(&["a", "b"]));
772
773 token.cancel();
774 tokio::time::sleep(CHANGE_POLL_INTERVAL + Duration::from_millis(200)).await;
775 let rev = f.snapshot().rev;
776 *source.lock() = names(&["c"]);
777 tokio::time::sleep(CHANGE_POLL_INTERVAL * 2).await;
778 assert_eq!(f.snapshot().rev, rev, "no updates after cancel");
779 }
780}