1use std::collections::{BTreeMap, BTreeSet};
23use std::net::{IpAddr, Ipv4Addr};
24use std::str::FromStr;
25
26pub const GRACE: std::time::Duration = std::time::Duration::from_secs(5);
30
31pub const STRIKES_BEFORE_REBUILD: u32 = 2;
34
35pub const RETRY_AFTER_TICKS: &[u32] = &[2, 4, 8, 20, 40];
46
47pub const MAX_DOWN_RETRIES: u32 = 6;
52
53pub fn should_publish(health: &MdnsHealth, changed: bool) -> bool {
63 match health {
64 MdnsHealth::Pending => false,
65 MdnsHealth::Healthy => changed,
66 MdnsHealth::Degraded { .. } | MdnsHealth::Undiscoverable { .. } => true,
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Default)]
71pub enum MdnsHealth {
72 #[default]
74 Pending,
75 Healthy,
78 Degraded { missing: Vec<Ipv4Addr> },
81 Undiscoverable { missing: Vec<Ipv4Addr> },
83}
84
85impl MdnsHealth {
86 #[cfg(test)]
87 pub fn is_undiscoverable(&self) -> bool {
88 matches!(self, MdnsHealth::Undiscoverable { .. })
89 }
90
91 pub fn wire_state(&self) -> &'static str {
93 match self {
94 MdnsHealth::Pending => "pending",
95 MdnsHealth::Healthy => "healthy",
96 MdnsHealth::Degraded { .. } => "degraded",
97 MdnsHealth::Undiscoverable { .. } => "undiscoverable",
98 }
99 }
100
101 pub fn missing(&self) -> &[Ipv4Addr] {
102 match self {
103 MdnsHealth::Degraded { missing } | MdnsHealth::Undiscoverable { missing } => missing,
104 _ => &[],
105 }
106 }
107}
108
109pub fn ipv4_in(payload: &str) -> Vec<Ipv4Addr> {
115 payload
116 .split(|c: char| !(c.is_ascii_digit() || c == '.'))
117 .filter(|t| !t.is_empty())
118 .filter_map(|t| Ipv4Addr::from_str(t).ok())
119 .collect()
120}
121
122pub fn expected_by_interface(
131 local: impl IntoIterator<Item = (String, IpAddr)>,
132) -> Vec<BTreeSet<Ipv4Addr>> {
133 let mut by_name: BTreeMap<String, BTreeSet<Ipv4Addr>> = BTreeMap::new();
134 for (name, addr) in local {
135 let IpAddr::V4(v4) = addr else { continue };
136 if v4.is_loopback()
137 || v4.is_unspecified()
138 || v4.is_link_local()
139 || v4.is_multicast()
140 || v4.is_broadcast()
141 {
142 continue;
143 }
144 by_name.entry(name).or_default().insert(v4);
145 }
146 by_name.into_values().collect()
147}
148
149pub fn evaluate(expected: &[BTreeSet<Ipv4Addr>], announced: &BTreeSet<Ipv4Addr>) -> MdnsHealth {
153 if expected.is_empty() {
154 return MdnsHealth::Healthy;
155 }
156 let missing: Vec<Ipv4Addr> = expected
157 .iter()
158 .filter(|iface| iface.is_disjoint(announced))
159 .filter_map(|iface| iface.iter().next().copied())
160 .collect();
161 if missing.is_empty() {
162 MdnsHealth::Healthy
163 } else if missing.len() == expected.len() {
164 MdnsHealth::Undiscoverable { missing }
165 } else {
166 MdnsHealth::Degraded { missing }
167 }
168}
169
170pub fn verdict(
178 monitored: bool,
179 since_start: std::time::Duration,
180 expected: &[BTreeSet<Ipv4Addr>],
181 announced: &BTreeSet<Ipv4Addr>,
182) -> MdnsHealth {
183 if !monitored || since_start < GRACE {
184 return MdnsHealth::Pending;
185 }
186 evaluate(expected, announced)
187}
188
189#[derive(Debug, PartialEq, Eq)]
190pub enum Action {
191 Nothing,
192 Rebuild,
194}
195
196#[derive(Debug, Default)]
198pub struct Watchdog {
199 strikes: u32,
200 rebuilds: u32,
201 cooldown: u32,
203 down_retries: u32,
204}
205
206impl Watchdog {
207 pub fn rebuilds(&self) -> u32 {
208 self.rebuilds
209 }
210
211 pub fn retry_in_ticks(&self) -> u32 {
213 self.cooldown
214 }
215
216 pub fn reset(&mut self) {
218 *self = Watchdog::default();
219 }
220
221 pub fn observe_down(&mut self) -> bool {
225 if self.down_retries < MAX_DOWN_RETRIES {
226 self.down_retries += 1;
227 true
228 } else {
229 false
230 }
231 }
232
233 pub fn observe(&mut self, health: &MdnsHealth) -> Action {
234 self.down_retries = 0;
236 self.cooldown = self.cooldown.saturating_sub(1);
238 match health {
239 MdnsHealth::Pending => Action::Nothing,
241 MdnsHealth::Healthy => {
242 *self = Watchdog { down_retries: 0, ..Watchdog::default() };
243 Action::Nothing
244 }
245 MdnsHealth::Degraded { .. } => {
246 self.strikes = 0;
247 Action::Nothing
248 }
249 MdnsHealth::Undiscoverable { .. } => {
250 if self.rebuilds == 0 {
251 self.strikes += 1;
253 if self.strikes < STRIKES_BEFORE_REBUILD {
254 return Action::Nothing;
255 }
256 } else if self.cooldown > 0 {
257 return Action::Nothing;
259 }
260 self.strikes = 0;
261 self.rebuilds += 1;
262 let step = (self.rebuilds as usize - 1).min(RETRY_AFTER_TICKS.len() - 1);
263 self.cooldown = RETRY_AFTER_TICKS[step];
264 Action::Rebuild
265 }
266 }
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 fn ip(s: &str) -> Ipv4Addr {
275 s.parse().unwrap()
276 }
277 fn set(v: &[&str]) -> BTreeSet<Ipv4Addr> {
278 v.iter().map(|s| ip(s)).collect()
279 }
280 fn ifaces(v: &[&str]) -> Vec<BTreeSet<Ipv4Addr>> {
282 v.iter().map(|s| set(&[s])).collect()
283 }
284 fn undiscoverable() -> MdnsHealth {
285 MdnsHealth::Undiscoverable { missing: vec![ip("192.168.1.26")] }
286 }
287
288 #[test]
289 fn parses_the_registration_payload_shape() {
290 let got = ipv4_in("[192.168.1.26, 172.17.16.1, fe80::1]");
292 assert_eq!(got, vec![ip("192.168.1.26"), ip("172.17.16.1")]);
293 }
294
295 #[test]
296 fn parses_the_host_colon_addr_payload_shape() {
297 assert_eq!(ipv4_in("Area54.local.:192.168.1.26"), vec![ip("192.168.1.26")]);
298 assert!(ipv4_in("Area54.local.").is_empty());
300 assert!(ipv4_in("").is_empty());
301 }
302
303 #[test]
304 fn expected_drops_everything_a_peer_could_not_use() {
305 let local = [
306 ("lo", "127.0.0.1"),
307 ("any", "0.0.0.0"),
308 ("apipa", "169.254.21.81"),
309 ("mc", "224.0.0.251"),
310 ("bc", "255.255.255.255"),
311 ("Ethernet", "192.168.1.26"),
312 ("vEthernet", "172.17.16.1"),
313 ("v6only", "fe80::1"),
314 ]
315 .map(|(n, a)| (n.to_string(), a.parse::<IpAddr>().unwrap()));
316 assert_eq!(expected_by_interface(local), vec![set(&["192.168.1.26"]), set(&["172.17.16.1"])]);
317 }
318
319 #[test]
320 fn two_addresses_on_one_interface_are_one_group() {
321 let local = [("Ethernet", "192.168.1.26"), ("Ethernet", "192.168.1.27"), ("Wi-Fi", "10.0.0.5")]
322 .map(|(n, a)| (n.to_string(), a.parse::<IpAddr>().unwrap()));
323 assert_eq!(
324 expected_by_interface(local),
325 vec![set(&["192.168.1.26", "192.168.1.27"]), set(&["10.0.0.5"])]
326 );
327 }
328
329 #[test]
330 fn nothing_to_announce_on_is_not_a_fault() {
331 assert_eq!(evaluate(&[], &set(&[])), MdnsHealth::Healthy);
332 }
333
334 #[test]
335 fn announced_on_all_expected_is_healthy() {
336 let e = ifaces(&["192.168.1.26", "172.17.16.1"]);
337 assert_eq!(evaluate(&e, &set(&["192.168.1.26", "172.17.16.1"])), MdnsHealth::Healthy);
338 }
339
340 #[test]
343 fn an_interface_is_covered_by_any_one_of_its_addresses() {
344 let e = vec![set(&["192.168.1.26", "192.168.1.27"])];
345 assert_eq!(evaluate(&e, &set(&["192.168.1.26"])), MdnsHealth::Healthy);
346 assert_eq!(evaluate(&e, &set(&["192.168.1.27"])), MdnsHealth::Healthy);
347 }
348
349 #[test]
350 fn announcements_outside_the_expected_set_prove_nothing() {
351 let e = ifaces(&["192.168.1.26"]);
352 assert!(evaluate(&e, &set(&["10.9.9.9"])).is_undiscoverable());
353 }
354
355 #[test]
356 fn the_area54_case_is_undiscoverable() {
357 let e = ifaces(&["192.168.1.26", "172.17.16.1"]);
359 let h = evaluate(&e, &set(&[]));
360 assert_eq!(h.wire_state(), "undiscoverable");
361 assert_eq!(h.missing(), &[ip("192.168.1.26"), ip("172.17.16.1")]);
362 }
363
364 #[test]
365 fn a_missing_virtual_adapter_alone_is_only_degraded() {
366 let e = ifaces(&["192.168.1.26", "172.17.16.1"]);
367 let h = evaluate(&e, &set(&["192.168.1.26"]));
368 assert_eq!(h, MdnsHealth::Degraded { missing: vec![ip("172.17.16.1")] });
369 assert!(!h.is_undiscoverable());
370 }
371
372 #[test]
373 fn a_standing_problem_is_resent_every_tick_so_a_late_window_learns_of_it() {
374 let bad = undiscoverable();
376 for _ in 0..3 {
377 assert!(should_publish(&bad, false), "unchanged but still broken: resend");
378 }
379 let degraded = MdnsHealth::Degraded { missing: vec![ip("172.17.16.1")] };
380 assert!(should_publish(°raded, false));
381 }
382
383 #[test]
384 fn healthy_is_only_sent_on_change_and_pending_never() {
385 assert!(should_publish(&MdnsHealth::Healthy, true), "recovery must clear a warning");
386 assert!(!should_publish(&MdnsHealth::Healthy, false));
387 assert!(!should_publish(&MdnsHealth::Pending, true));
388 assert!(!should_publish(&MdnsHealth::Pending, false));
389 }
390
391 #[test]
392 fn a_down_daemon_is_retried_a_bounded_number_of_times() {
393 let mut w = Watchdog::default();
394 let tries = (0..20).filter(|_| w.observe_down()).count() as u32;
395 assert_eq!(tries, MAX_DOWN_RETRIES);
396 }
397
398 #[test]
399 fn a_failed_rebuild_does_not_wipe_the_rebuild_budget() {
400 let mut w = Watchdog::default();
403 w.observe(&undiscoverable());
404 assert_eq!(w.observe(&undiscoverable()), Action::Rebuild);
405 assert_eq!(w.rebuilds(), 1);
406 assert!(w.observe_down());
407 assert!(w.observe_down());
408 assert_eq!(w.rebuilds(), 1, "still counted while the daemon is down");
409 }
410
411 #[test]
412 fn a_daemon_coming_back_restores_the_retries() {
413 let mut w = Watchdog::default();
414 while w.observe_down() {}
415 assert!(!w.observe_down(), "exhausted");
416 w.observe(&MdnsHealth::Pending); assert!(w.observe_down(), "retries start over");
418 }
419
420 #[test]
421 fn no_monitor_means_no_verdict_however_long_it_has_been() {
422 let e = ifaces(&["192.168.1.26"]);
424 let long_ago = GRACE * 100;
425 assert_eq!(verdict(false, long_ago, &e, &set(&[])), MdnsHealth::Pending);
426 assert_eq!(verdict(false, long_ago, &e, &set(&["192.168.1.26"])), MdnsHealth::Pending);
427 }
428
429 #[test]
430 fn a_monitored_daemon_gets_its_start_up_grace_then_a_verdict() {
431 let e = ifaces(&["192.168.1.26"]);
432 assert_eq!(verdict(true, GRACE / 2, &e, &set(&[])), MdnsHealth::Pending);
433 assert!(verdict(true, GRACE, &e, &set(&[])).is_undiscoverable());
434 assert_eq!(verdict(true, GRACE, &e, &set(&["192.168.1.26"])), MdnsHealth::Healthy);
435 }
436
437 #[test]
438 fn one_miss_is_not_enough_to_rebuild() {
439 let mut w = Watchdog::default();
440 assert_eq!(w.observe(&undiscoverable()), Action::Nothing);
441 assert_eq!(w.rebuilds(), 0);
442 }
443
444 #[test]
445 fn two_consecutive_misses_rebuild_and_a_pending_verdict_does_not_count() {
446 let mut w = Watchdog::default();
447 assert_eq!(w.observe(&undiscoverable()), Action::Nothing);
448 assert_eq!(w.observe(&MdnsHealth::Pending), Action::Nothing);
450 assert_eq!(w.observe(&undiscoverable()), Action::Rebuild);
451 assert_eq!(w.rebuilds(), 1);
452 }
453
454 #[test]
455 fn a_healthy_tick_resets_the_strikes() {
456 let mut w = Watchdog::default();
457 w.observe(&undiscoverable());
458 w.observe(&MdnsHealth::Healthy);
459 assert_eq!(w.observe(&undiscoverable()), Action::Nothing, "strikes restarted");
460 }
461
462 #[test]
466 fn rebuilds_back_off_and_never_stop() {
467 let mut w = Watchdog::default();
468 let mut at = Vec::new();
469 for tick in 1..=400u32 {
470 if w.observe(&undiscoverable()) == Action::Rebuild {
471 at.push(tick);
472 }
473 }
474 let gaps: Vec<u32> = at.windows(2).map(|p| p[1] - p[0]).collect();
475 assert_eq!(at[0], 2, "the first rebuild needs two misses in a row");
476 assert_eq!(&gaps[..5], &[2, 4, 8, 20, 40], "{at:?}");
477 assert!(gaps[5..].iter().all(|g| *g == 40), "settles at one attempt per 40 ticks: {gaps:?}");
478 assert!(400 - at.last().unwrap() < 40, "still retrying at the end: {at:?}");
479 assert!(at.len() > 10, "far more than the old cap of 3: {}", at.len());
480 }
481
482 #[test]
486 fn a_holder_that_lets_go_minutes_later_is_still_recovered() {
487 const HOLDER_LEAVES_AT_TICK: u32 = 28; let mut w = Watchdog::default();
489 let mut recovered_ok = false;
490 let mut recovered_at = None;
491 for tick in 1..=200u32 {
492 let health = if recovered_ok { MdnsHealth::Healthy } else { undiscoverable() };
493 if w.observe(&health) == Action::Rebuild {
494 recovered_ok = tick >= HOLDER_LEAVES_AT_TICK;
496 }
497 if recovered_ok && recovered_at.is_none() {
498 recovered_at = Some(tick);
499 }
500 }
501 let at = recovered_at.expect("it must recover once the holder lets go");
502 assert!((HOLDER_LEAVES_AT_TICK..HOLDER_LEAVES_AT_TICK + 40).contains(&at), "recovered at tick {at}");
503 assert_eq!(w.rebuilds(), 0, "full health restores the budget");
504 }
505
506 #[test]
507 fn the_next_attempt_time_is_exposed_for_the_log_line() {
508 let mut w = Watchdog::default();
509 w.observe(&undiscoverable());
510 assert_eq!(w.observe(&undiscoverable()), Action::Rebuild);
511 assert_eq!(w.retry_in_ticks(), RETRY_AFTER_TICKS[0]);
512 }
513
514 #[test]
515 fn recovery_restores_the_rebuild_budget() {
516 let mut w = Watchdog::default();
517 for _ in 0..40 {
518 w.observe(&undiscoverable());
519 }
520 w.observe(&MdnsHealth::Healthy);
521 assert_eq!(w.rebuilds(), 0);
522 w.observe(&undiscoverable());
523 assert_eq!(w.observe(&undiscoverable()), Action::Rebuild);
524 }
525
526 #[test]
527 fn degraded_does_not_clear_the_rebuild_count_but_does_clear_strikes() {
528 let mut w = Watchdog::default();
529 w.observe(&undiscoverable());
530 w.observe(&undiscoverable()); assert_eq!(w.rebuilds(), 1);
532 w.observe(&MdnsHealth::Degraded { missing: vec![ip("172.17.16.1")] });
533 assert_eq!(w.rebuilds(), 1, "only full health restores the budget");
534 }
535}