Skip to main content

sozu_lib/udp/
health.rs

1//! Endpoint-bound active health checks for UDP backends (MASTER-PLAN §7).
2//!
3//! UDP has no reliable liveness signal of its own, so health is bound to the
4//! **endpoint**, not the listener:
5//!
6//! - **Primary — companion TCP probe**: a non-blocking mio TCP `connect` to a
7//!   configurable `tcp_port` (default = the backend data port). Connection
8//!   established ⇒ healthy; refused/timeout ⇒ unhealthy. This is the
9//!   industry-standard signal (IPVS/Keepalived, HAProxy `check port`, Envoy
10//!   `health_check_config.port_value`, nginx `port=`). It is a *hint*, not
11//!   proof of UDP reachability — documented as such.
12//! - **Secondary — app UDP probe**: send a configured payload, expect any reply
13//!   within a timeout; silence ⇒ unhealthy. (State machine wired; see
14//!   [`UdpHealthChecker::owns_token`] notes on integration scope.)
15//!
16//! Results feed `Backend::health` via rise/fall hysteresis
17//! ([`HealthState`](crate::backends::HealthState)), so backend **selection**
18//! (`BackendMap::next_available_backend_with_key`) skips unhealthy backends.
19//! **Fail-open** is inherited: when every backend reads unhealthy the selection
20//! routes over the full configured set rather than black-holing. Flows already
21//! pinned to a now-unhealthy backend stay until idle-timeout (the flow table
22//! pins them; health only steers *new* selections).
23//!
24//! Everything runs **non-blocking in the event loop** — Sōzu has no worker
25//! background threads. Probes are scheduled off `probe_interval_seconds` and
26//! never block the loop.
27
28use std::{
29    cell::RefCell,
30    collections::{HashMap, HashSet},
31    io::ErrorKind,
32    net::SocketAddr,
33    rc::{Rc, Weak},
34    time::{Duration, Instant},
35};
36
37use mio::{
38    Interest, Registry, Token,
39    net::{TcpStream, UdpSocket},
40};
41use sozu_command::{proto::command::UdpHealthConfig, state::ClusterId};
42
43use crate::backends::{Backend, BackendMap};
44use crate::metrics::names;
45use crate::socket::udp_connect;
46
47/// Canonical log envelope tag for the UDP health prober.
48macro_rules! log_context {
49    () => {
50        "UDP-HEALTH"
51    };
52}
53
54/// Base of the dedicated mio token namespace for UDP health-check sockets.
55/// Chosen disjoint from the HTTP `HealthChecker` range (`1 << 24`) and from
56/// slab session tokens: `[BASE, BASE + CAPACITY)`.
57const UDP_HEALTH_TOKEN_BASE: usize = (1 << 24) + (1 << 20);
58/// Maximum concurrent in-flight UDP health probes.
59const UDP_HEALTH_TOKEN_CAPACITY: usize = 1 << 16;
60
61/// Resolved per-cluster UDP health configuration, captured from the cluster's
62/// `UdpHealthConfig` (proto) at registration time so the prober does not race a
63/// mid-probe reconfig.
64#[derive(Clone, Debug)]
65pub struct UdpHealthSettings {
66    /// Companion TCP probe port; `None` = the backend data port.
67    pub tcp_port: Option<u16>,
68    /// Consecutive successes before a backend is marked up.
69    pub rise: u32,
70    /// Consecutive failures before a backend is marked down.
71    pub fall: u32,
72    /// Seconds between probe cycles.
73    pub interval: Duration,
74    /// Per-probe response timeout.
75    pub timeout: Duration,
76    /// Optional app UDP-probe payload (secondary signal).
77    pub udp_probe_payload: Option<Vec<u8>>,
78}
79
80impl UdpHealthSettings {
81    /// Build the resolved settings from a proto `UdpHealthConfig`, applying the
82    /// documented defaults (rise 2, fall 3, interval 5s, timeout 2s).
83    pub fn from_proto(cfg: &UdpHealthConfig) -> Self {
84        UdpHealthSettings {
85            tcp_port: cfg.tcp_port.map(|p| p as u16),
86            rise: cfg.rise.unwrap_or(2),
87            fall: cfg.fall.unwrap_or(3),
88            interval: Duration::from_secs(u64::from(cfg.probe_interval_seconds.unwrap_or(5))),
89            timeout: Duration::from_secs(u64::from(cfg.probe_timeout_seconds.unwrap_or(2))),
90            udp_probe_payload: cfg.udp_probe_payload.clone(),
91        }
92    }
93}
94
95/// A batch of backends due for probing, grouped by cluster:
96/// `(cluster_id, resolved settings, [(address, incarnation)])`.
97type ProbeBatch = Vec<(ClusterId, UdpHealthSettings, Vec<ProbeTarget>)>;
98
99/// A backend due for probing: `(address, incarnation)`.
100type ProbeTarget = (SocketAddr, Weak<RefCell<Backend>>);
101
102/// The transport of an in-flight probe.
103///
104/// - `Tcp` is the **primary** companion probe: a writable, error-free connected
105///   TCP socket is the liveness hint.
106/// - `Udp` is the **secondary** app-level probe: a configured payload was sent
107///   on a connected UDP socket; **any** readable reply within the timeout is the
108///   success signal, silence (timeout) is failure.
109enum ProbeSocket {
110    Tcp(TcpStream),
111    Udp(UdpSocket),
112}
113
114impl ProbeSocket {
115    /// Deregister the underlying socket from the mio registry on completion.
116    fn deregister(&mut self, registry: &Registry) {
117        match self {
118            ProbeSocket::Tcp(s) => {
119                let _ = registry.deregister(s);
120            }
121            ProbeSocket::Udp(s) => {
122                let _ = registry.deregister(s);
123            }
124        }
125    }
126}
127
128/// An in-flight non-blocking probe (TCP companion or UDP app-level).
129struct InFlightProbe {
130    socket: ProbeSocket,
131    token: Token,
132    cluster_id: ClusterId,
133    /// The backend incarnation this probe was launched for; see
134    /// [`UdpHealthChecker::record`] (#1821).
135    backend: Weak<RefCell<Backend>>,
136    started_at: Instant,
137    timeout: Duration,
138    rise: u32,
139    fall: u32,
140}
141
142/// Timer-driven, non-blocking UDP backend health prober. Owned by `UdpProxy`;
143/// driven from the server event loop exactly like the HTTP `HealthChecker`
144/// (`poll` once per iteration, `ready(token)` on owned readiness).
145#[derive(Default)]
146pub struct UdpHealthChecker {
147    /// Per-cluster resolved settings, set when an `AddCluster` carries a `udp`
148    /// block with a health config.
149    settings: HashMap<ClusterId, UdpHealthSettings>,
150    in_flight: Vec<InFlightProbe>,
151    last_check: HashMap<ClusterId, Instant>,
152    next_token_id: usize,
153    ready_tokens: HashSet<Token>,
154}
155
156impl UdpHealthChecker {
157    pub fn new() -> Self {
158        Self::default()
159    }
160
161    /// Register (or replace) a cluster's health settings. `None` removes them
162    /// (health disabled for that cluster). When removing, in-flight probes for
163    /// the cluster are **deregistered from the mio registry before being
164    /// dropped** — mirroring the completion path in `Self::progress`. Skipping
165    /// the deregister (as the old code did) leaked the probe fd's registration
166    /// until the socket was eventually closed, and could leave a stale token
167    /// mapping in the poller.
168    pub fn set_cluster(
169        &mut self,
170        cluster_id: &str,
171        settings: Option<UdpHealthSettings>,
172        registry: &Registry,
173    ) {
174        match settings {
175            Some(s) => {
176                self.settings.insert(cluster_id.into(), s);
177            }
178            None => {
179                self.settings.remove(cluster_id);
180                self.last_check.remove(cluster_id);
181                // Partition in place: deregister the probes belonging to the
182                // removed cluster, keep the rest. `retain` cannot deregister
183                // (the predicate gets `&`, deregister needs `&mut`), so drain +
184                // refill.
185                let mut kept = Vec::with_capacity(self.in_flight.len());
186                for mut probe in self.in_flight.drain(..) {
187                    if *probe.cluster_id == *cluster_id {
188                        probe.socket.deregister(registry);
189                    } else {
190                        kept.push(probe);
191                    }
192                }
193                self.in_flight = kept;
194            }
195        }
196    }
197
198    /// Drop all state for a removed cluster, deregistering any in-flight probes.
199    pub fn remove_cluster(&mut self, cluster_id: &str, registry: &Registry) {
200        self.set_cluster(cluster_id, None, registry);
201    }
202
203    /// Whether `token` falls in the reserved UDP-health namespace.
204    pub fn owns_token(&self, token: Token) -> bool {
205        token.0 >= UDP_HEALTH_TOKEN_BASE
206            && token.0 < UDP_HEALTH_TOKEN_BASE + UDP_HEALTH_TOKEN_CAPACITY
207    }
208
209    /// Record mio readiness for an owned probe socket.
210    pub fn ready(&mut self, token: Token) {
211        self.ready_tokens.insert(token);
212    }
213
214    /// Pick the next free probe token offset, skipping in-flight slots.
215    fn allocate_token(&mut self) -> Option<Token> {
216        let in_flight: HashSet<usize> = self
217            .in_flight
218            .iter()
219            .map(|p| p.token.0 - UDP_HEALTH_TOKEN_BASE)
220            .collect();
221        for _ in 0..UDP_HEALTH_TOKEN_CAPACITY {
222            let offset = self.next_token_id % UDP_HEALTH_TOKEN_CAPACITY;
223            self.next_token_id = self.next_token_id.wrapping_add(1);
224            if !in_flight.contains(&offset) {
225                let token = Token(UDP_HEALTH_TOKEN_BASE + offset);
226                // A freshly allocated probe token must fall in the reserved
227                // health namespace (so the proxy demuxes its readiness back to
228                // the health checker) and must not collide with an in-flight
229                // probe (which would alias two probes onto one socket slot).
230                debug_assert!(
231                    self.owns_token(token),
232                    "allocate_token returned a token outside the health namespace"
233                );
234                debug_assert!(
235                    !self.in_flight.iter().any(|p| p.token == token),
236                    "allocate_token returned a token already in flight"
237                );
238                return Some(token);
239            }
240        }
241        error!(
242            "{} token table full ({} in-flight); refusing new probe slot",
243            log_context!(),
244            in_flight.len()
245        );
246        None
247    }
248
249    /// One event-loop step: start due probes, progress in-flight ones. Never
250    /// blocks. No-op when no cluster has health configured and nothing is in
251    /// flight.
252    pub fn poll(&mut self, backends: &Rc<RefCell<BackendMap>>, registry: &Registry) {
253        if self.settings.is_empty() && self.in_flight.is_empty() {
254            return;
255        }
256        self.initiate(backends, registry);
257        self.progress(backends, registry);
258    }
259
260    fn initiate(&mut self, backends: &Rc<RefCell<BackendMap>>, registry: &Registry) {
261        let now = Instant::now();
262        let backend_map = backends.borrow();
263
264        // Collect (cluster, settings, [(address, incarnation)]) to probe.
265        let mut to_probe: ProbeBatch = Vec::new();
266        for (cluster_id, settings) in &self.settings {
267            let due = match self.last_check.get(cluster_id) {
268                Some(last) => now.duration_since(*last) >= settings.interval,
269                None => true,
270            };
271            if !due {
272                continue;
273            }
274            if let Some(list) = backend_map.backends.get(cluster_id) {
275                // Probes in flight are tracked per backend incarnation: one
276                // still draining for a removed incarnation does not delay
277                // the first probe of its replacement.
278                let targets: Vec<ProbeTarget> = list
279                    .backends
280                    .iter()
281                    .filter(|b| {
282                        !self
283                            .in_flight
284                            .iter()
285                            .any(|p| std::ptr::eq(p.backend.as_ptr(), Rc::as_ptr(b)))
286                    })
287                    .map(|b| (b.borrow().address, Rc::downgrade(b)))
288                    .collect();
289                if !targets.is_empty() {
290                    to_probe.push((cluster_id.to_owned(), settings.clone(), targets));
291                }
292            }
293        }
294        drop(backend_map);
295
296        for (cluster_id, settings, targets) in to_probe {
297            self.last_check.insert(cluster_id.to_owned(), now);
298            for (address, incarnation) in targets {
299                // Primary: companion TCP probe (always runs).
300                self.spawn_tcp_probe(
301                    backends,
302                    registry,
303                    &cluster_id,
304                    address,
305                    &incarnation,
306                    &settings,
307                    now,
308                );
309                // Secondary: app-level UDP probe, only when a payload is
310                // configured. Its result feeds the SAME rise/fall hysteresis, so
311                // a configured app probe that goes silent can mark a backend down
312                // even while the TCP port answers, and a reply keeps it up.
313                if settings.udp_probe_payload.is_some() {
314                    self.spawn_udp_probe(
315                        backends,
316                        registry,
317                        &cluster_id,
318                        address,
319                        &incarnation,
320                        &settings,
321                        now,
322                    );
323                }
324            }
325        }
326    }
327
328    /// Spawn one primary companion TCP probe toward `tcp_port` (override) or the
329    /// backend data port. Failure to connect/register records a failure straight
330    /// away (no in-flight slot).
331    #[allow(clippy::too_many_arguments)]
332    fn spawn_tcp_probe(
333        &mut self,
334        backends: &Rc<RefCell<BackendMap>>,
335        registry: &Registry,
336        cluster_id: &str,
337        address: SocketAddr,
338        incarnation: &Weak<RefCell<Backend>>,
339        settings: &UdpHealthSettings,
340        now: Instant,
341    ) {
342        let probe_addr = match settings.tcp_port {
343            Some(port) => SocketAddr::new(address.ip(), port),
344            None => address,
345        };
346        let record_failure = || {
347            Self::record(
348                backends,
349                cluster_id,
350                incarnation,
351                false,
352                settings.rise,
353                settings.fall,
354            )
355        };
356        let mut stream = match TcpStream::connect(probe_addr) {
357            Ok(stream) => stream,
358            Err(_) => return record_failure(),
359        };
360        let Some(token) = self.allocate_token() else {
361            return record_failure();
362        };
363        if registry
364            .register(&mut stream, token, Interest::WRITABLE)
365            .is_err()
366        {
367            return record_failure();
368        }
369        self.in_flight.push(InFlightProbe {
370            socket: ProbeSocket::Tcp(stream),
371            token,
372            cluster_id: cluster_id.into(),
373            backend: incarnation.to_owned(),
374            started_at: now,
375            timeout: settings.timeout,
376            rise: settings.rise,
377            fall: settings.fall,
378        });
379    }
380
381    /// Spawn one secondary app-level UDP probe: a connected non-blocking UDP
382    /// socket to the backend data port, the configured payload sent once, and
383    /// `Interest::READABLE` armed so any reply wakes [`Self::progress`]. A send
384    /// or socket-setup error records a failure immediately; silence is failed by
385    /// the timeout in `progress`.
386    #[allow(clippy::too_many_arguments)]
387    fn spawn_udp_probe(
388        &mut self,
389        backends: &Rc<RefCell<BackendMap>>,
390        registry: &Registry,
391        cluster_id: &str,
392        address: SocketAddr,
393        incarnation: &Weak<RefCell<Backend>>,
394        settings: &UdpHealthSettings,
395        now: Instant,
396    ) {
397        let Some(payload) = settings.udp_probe_payload.as_deref() else {
398            return;
399        };
400        let record_failure = || {
401            Self::record(
402                backends,
403                cluster_id,
404                incarnation,
405                false,
406                settings.rise,
407                settings.fall,
408            )
409        };
410        // The app probe always targets the backend data port (the UDP service),
411        // never `tcp_port` (that is the companion-TCP override only).
412        let mut socket = match udp_connect(address) {
413            Ok(socket) => socket,
414            Err(_) => return record_failure(),
415        };
416        // Best-effort send of the probe payload. `WouldBlock` is treated as a
417        // soft failure to send (rare on a fresh connected socket); a hard error
418        // (e.g. immediate ECONNREFUSED) is a failure signal.
419        match socket.send(payload) {
420            Ok(_) => {}
421            Err(ref e) if e.kind() == ErrorKind::WouldBlock => return record_failure(),
422            Err(_) => return record_failure(),
423        }
424        let Some(token) = self.allocate_token() else {
425            return record_failure();
426        };
427        if registry
428            .register(&mut socket, token, Interest::READABLE)
429            .is_err()
430        {
431            return record_failure();
432        }
433        self.in_flight.push(InFlightProbe {
434            socket: ProbeSocket::Udp(socket),
435            token,
436            cluster_id: cluster_id.into(),
437            backend: incarnation.to_owned(),
438            started_at: now,
439            timeout: settings.timeout,
440            rise: settings.rise,
441            fall: settings.fall,
442        });
443    }
444
445    fn progress(&mut self, backends: &Rc<RefCell<BackendMap>>, registry: &Registry) {
446        let now = Instant::now();
447        let ready = std::mem::take(&mut self.ready_tokens);
448        let mut completed: Vec<(usize, bool)> = Vec::new();
449
450        for (idx, probe) in self.in_flight.iter_mut().enumerate() {
451            if now.duration_since(probe.started_at) > probe.timeout {
452                completed.push((idx, false));
453                continue;
454            }
455            if !ready.contains(&probe.token) {
456                continue;
457            }
458            let success = match &mut probe.socket {
459                // A writable, error-free connected TCP socket = probe success. A
460                // refused/aborted connect surfaces either as a non-`None`
461                // `SO_ERROR` (via `take_error`) or as a `peer_addr` failure (the
462                // 3-way handshake never completed), so require both to be clean.
463                ProbeSocket::Tcp(stream) => {
464                    let no_so_error = matches!(stream.take_error(), Ok(None));
465                    no_so_error && stream.peer_addr().is_ok()
466                }
467                // ANY readable reply on the connected UDP probe socket within the
468                // timeout is success; a `WouldBlock` here means the readiness was
469                // spurious (no datagram yet) — leave it in flight to either read
470                // a reply on a later edge or time out.
471                ProbeSocket::Udp(socket) => {
472                    let mut scratch = [0u8; 16];
473                    match socket.recv(&mut scratch) {
474                        Ok(_) => true,
475                        Err(ref e) if e.kind() == ErrorKind::WouldBlock => continue,
476                        // A connected UDP socket surfaces a prior ICMP
477                        // port-unreachable as ECONNREFUSED on recv → failure.
478                        Err(_) => false,
479                    }
480                }
481            };
482            completed.push((idx, success));
483        }
484
485        completed.sort_by_key(|entry| std::cmp::Reverse(entry.0));
486        for (idx, success) in completed {
487            let mut probe = self.in_flight.swap_remove(idx);
488            probe.socket.deregister(registry);
489            Self::record(
490                backends,
491                &probe.cluster_id,
492                &probe.backend,
493                success,
494                probe.rise,
495                probe.fall,
496            );
497        }
498    }
499
500    /// Apply a probe result to the `HealthState` of the backend
501    /// `incarnation` it was launched for, with rise/fall hysteresis, log +
502    /// count transitions, and re-evaluate cluster availability so fail-open /
503    /// recovery transitions surface. A result whose incarnation left the
504    /// cluster (removed, or replaced by a re-add of the same id and address)
505    /// is dropped; it never lands on a sibling at the same address nor on the
506    /// replacement (#1821).
507    fn record(
508        backends: &Rc<RefCell<BackendMap>>,
509        cluster_id: &str,
510        incarnation: &Weak<RefCell<Backend>>,
511        success: bool,
512        rise: u32,
513        fall: u32,
514    ) {
515        let mut backend_map = backends.borrow_mut();
516        let Some(list) = backend_map.backends.get_mut(cluster_id) else {
517            return;
518        };
519        let Some(backend_ref) = list.find_incarnation(incarnation) else {
520            debug!(
521                "{} dropping a result for a backend no longer in cluster {}",
522                log_context!(),
523                cluster_id
524            );
525            return;
526        };
527        let mut backend = backend_ref.borrow_mut();
528        let backend_id = backend.backend_id.to_owned();
529        let address = backend.address;
530        if success {
531            if backend.health.record_success(rise) {
532                info!(
533                    "{} backend {} at {} marked UP (cluster {})",
534                    log_context!(),
535                    backend_id,
536                    address,
537                    cluster_id
538                );
539                incr!(names::udp::BACKEND_HEALTH);
540            }
541        } else if backend.health.record_failure(fall) {
542            warn!(
543                "{} backend {} at {} marked DOWN (cluster {})",
544                log_context!(),
545                backend_id,
546                address,
547                cluster_id
548            );
549            incr!(names::udp::BACKEND_HEALTH);
550        }
551        drop(backend);
552        backend_map.record_cluster_availability(cluster_id);
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use crate::backends::HealthState;
560
561    #[test]
562    fn hysteresis_rise_fall() {
563        // rise=2, fall=3: a single failure does not flip a healthy backend, and
564        // a single success does not flip an unhealthy one.
565        let mut state = HealthState::default();
566        assert!(state.is_healthy());
567        assert!(!state.record_failure(3));
568        assert!(!state.record_failure(3));
569        assert!(state.is_healthy());
570        assert!(state.record_failure(3));
571        assert!(!state.is_healthy());
572        assert!(!state.record_success(2));
573        assert!(state.record_success(2));
574        assert!(state.is_healthy());
575    }
576
577    #[test]
578    fn token_namespace_is_disjoint_and_owned() {
579        let hc = UdpHealthChecker::new();
580        assert!(hc.owns_token(Token(UDP_HEALTH_TOKEN_BASE)));
581        assert!(hc.owns_token(Token(UDP_HEALTH_TOKEN_BASE + UDP_HEALTH_TOKEN_CAPACITY - 1)));
582        assert!(!hc.owns_token(Token(UDP_HEALTH_TOKEN_BASE - 1)));
583        assert!(!hc.owns_token(Token(UDP_HEALTH_TOKEN_BASE + UDP_HEALTH_TOKEN_CAPACITY)));
584        // Disjoint from the HTTP HealthChecker base (1 << 24).
585        assert!(!hc.owns_token(Token(1 << 24)));
586    }
587
588    #[test]
589    fn settings_from_proto_defaults() {
590        let cfg = UdpHealthConfig {
591            mode: None,
592            tcp_port: Some(5353),
593            rise: None,
594            fall: None,
595            fail_open: None,
596            udp_probe_payload: None,
597            probe_interval_seconds: None,
598            probe_timeout_seconds: None,
599        };
600        let s = UdpHealthSettings::from_proto(&cfg);
601        assert_eq!(s.tcp_port, Some(5353));
602        assert_eq!(s.rise, 2);
603        assert_eq!(s.fall, 3);
604        assert_eq!(s.interval, Duration::from_secs(5));
605        assert_eq!(s.timeout, Duration::from_secs(2));
606    }
607
608    #[test]
609    fn udp_probe_payload_is_captured() {
610        // The secondary app-level probe is gated on `udp_probe_payload` being
611        // present; `from_proto` must surface it so `initiate` knows to spawn the
612        // UDP probe alongside the TCP one.
613        let cfg = UdpHealthConfig {
614            mode: Some(sozu_command::proto::command::UdpHealthMode::UdpProbe as i32),
615            tcp_port: None,
616            rise: Some(1),
617            fall: Some(1),
618            fail_open: None,
619            udp_probe_payload: Some(b"PING".to_vec()),
620            probe_interval_seconds: Some(1),
621            probe_timeout_seconds: Some(1),
622        };
623        let s = UdpHealthSettings::from_proto(&cfg);
624        assert_eq!(s.udp_probe_payload.as_deref(), Some(&b"PING"[..]));
625        assert_eq!(s.tcp_port, None);
626    }
627
628    /// Drive the **secondary UDP-probe** result through the SAME `record` path
629    /// (and thus the same rise/fall hysteresis) the TCP probe uses, proving a
630    /// silent app probe flips a backend DOWN and a reply flips it back UP. No
631    /// real I/O: we call `record` with the success/failure a UDP probe would
632    /// have produced.
633    #[test]
634    fn udp_probe_result_feeds_same_hysteresis() {
635        use crate::backends::{Backend, BackendMap};
636
637        let cluster = "dns";
638        let address: SocketAddr = ([127, 0, 0, 1], 5353).into();
639        let backend_map = Rc::new(RefCell::new(BackendMap::new()));
640        backend_map
641            .borrow_mut()
642            .add_backend(cluster, Backend::new("b1", address, None, None, None));
643        let (rise, fall) = (2u32, 3u32);
644        let b1 = Rc::downgrade(&backend_map.borrow().backends[cluster].backends[0]);
645
646        let is_healthy = |map: &Rc<RefCell<BackendMap>>| {
647            let mut m = map.borrow_mut();
648            let list = m.backends.get_mut(cluster).unwrap();
649            let b = list.find_backend(&address).unwrap();
650            b.borrow().health.is_healthy()
651        };
652        assert!(is_healthy(&backend_map));
653
654        // Secondary UDP probe goes silent: `fall` failures flip the backend DOWN.
655        for _ in 0..fall {
656            UdpHealthChecker::record(&backend_map, cluster, &b1, false, rise, fall);
657        }
658        assert!(!is_healthy(&backend_map));
659
660        // A reply arrives: `rise` successes flip it back UP (hysteresis holds —
661        // one success is not enough).
662        UdpHealthChecker::record(&backend_map, cluster, &b1, true, rise, fall);
663        assert!(!is_healthy(&backend_map));
664        UdpHealthChecker::record(&backend_map, cluster, &b1, true, rise, fall);
665        assert!(is_healthy(&backend_map));
666    }
667
668    /// The health of the backend `backend_id` in `cluster`.
669    fn is_healthy_by_id(map: &Rc<RefCell<BackendMap>>, cluster: &str, backend_id: &str) -> bool {
670        map.borrow().backends[cluster]
671            .backends
672            .iter()
673            .find(|backend| backend.borrow().backend_id == backend_id)
674            .expect("backend id is present")
675            .borrow()
676            .health
677            .is_healthy()
678    }
679
680    /// #1821: two ids may share an address; a probe result updates the id
681    /// that was probed, never the first sibling at that address.
682    #[test]
683    fn probe_result_updates_the_backend_id_that_was_probed() {
684        use crate::backends::{Backend, BackendMap};
685
686        let cluster = "dns";
687        let address: SocketAddr = ([127, 0, 0, 1], 5353).into();
688        let backend_map = Rc::new(RefCell::new(BackendMap::new()));
689        {
690            let mut map = backend_map.borrow_mut();
691            map.add_backend(cluster, Backend::new("a", address, None, None, None));
692            map.add_backend(cluster, Backend::new("b", address, None, None, None));
693        }
694        let probed = Rc::downgrade(
695            backend_map.borrow().backends[cluster]
696                .find_backend_by_identity("b", &address)
697                .expect("backend b is present"),
698        );
699
700        UdpHealthChecker::record(&backend_map, cluster, &probed, false, 1, 1);
701
702        assert_eq!(
703            (
704                is_healthy_by_id(&backend_map, cluster, "a"),
705                is_healthy_by_id(&backend_map, cluster, "b"),
706            ),
707            (true, false),
708            "a failed probe for backend b must mutate b, not the first backend at its address"
709        );
710    }
711
712    /// #1821: a probe launched before `RemoveBackend` that completes after
713    /// the same id and address are added again must not mutate the
714    /// replacement; a result for the replacement does apply.
715    #[test]
716    fn late_probe_result_after_remove_and_readd_does_not_mutate_replacement() {
717        use crate::backends::{Backend, BackendMap};
718
719        let cluster = "dns";
720        let address: SocketAddr = ([127, 0, 0, 1], 5354).into();
721        let backend_map = Rc::new(RefCell::new(BackendMap::new()));
722        backend_map
723            .borrow_mut()
724            .add_backend(cluster, Backend::new("b1", address, None, None, None));
725        // What `initiate` captures when it launches the probe, and a session
726        // still holding the removed backend, which keeps it alive.
727        let held = Rc::clone(&backend_map.borrow().backends[cluster].backends[0]);
728        let stale = Rc::downgrade(&held);
729
730        assert!(
731            backend_map
732                .borrow_mut()
733                .remove_backend(cluster, "b1", &address)
734        );
735        backend_map
736            .borrow_mut()
737            .add_backend(cluster, Backend::new("b1", address, None, None, None));
738
739        UdpHealthChecker::record(&backend_map, cluster, &stale, false, 1, 1);
740        assert!(
741            is_healthy_by_id(&backend_map, cluster, "b1"),
742            "a late result from the removed incarnation must not mark its replacement DOWN"
743        );
744
745        let current = Rc::downgrade(&backend_map.borrow().backends[cluster].backends[0]);
746        UdpHealthChecker::record(&backend_map, cluster, &current, false, 1, 1);
747        assert!(
748            !is_healthy_by_id(&backend_map, cluster, "b1"),
749            "a result for the live incarnation must mark it DOWN"
750        );
751    }
752
753    /// #1821: duplicate-probe detection is per backend incarnation. A probe
754    /// still in flight for a removed incarnation (kept alive by a session)
755    /// must not delay the first probe of a replacement re-added with the
756    /// same id and address.
757    #[test]
758    fn in_flight_probe_for_removed_incarnation_does_not_block_replacement_probe() {
759        use crate::backends::{Backend, BackendMap};
760
761        let cluster = "dns";
762        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
763        let address = listener.local_addr().unwrap();
764        let poll = mio::Poll::new().unwrap();
765        let backend_map = Rc::new(RefCell::new(BackendMap::new()));
766        backend_map
767            .borrow_mut()
768            .add_backend(cluster, Backend::new("b1", address, None, None, None));
769        let settings = UdpHealthSettings::from_proto(&UdpHealthConfig {
770            mode: None,
771            tcp_port: None,
772            rise: Some(1),
773            fall: Some(1),
774            fail_open: None,
775            udp_probe_payload: None,
776            probe_interval_seconds: Some(10),
777            probe_timeout_seconds: Some(5),
778        });
779        let mut checker = UdpHealthChecker::new();
780        checker.set_cluster(cluster, Some(settings), poll.registry());
781
782        checker.initiate(&backend_map, poll.registry());
783        assert_eq!(
784            checker.in_flight.len(),
785            1,
786            "the first cycle probes the backend"
787        );
788
789        // A session still holding the removed backend keeps it alive.
790        let _held = Rc::clone(&backend_map.borrow().backends[cluster].backends[0]);
791        assert!(
792            backend_map
793                .borrow_mut()
794                .remove_backend(cluster, "b1", &address)
795        );
796        backend_map
797            .borrow_mut()
798            .add_backend(cluster, Backend::new("b1", address, None, None, None));
799        let replacement = Rc::clone(&backend_map.borrow().backends[cluster].backends[0]);
800
801        // Make the cluster due again while the removed incarnation's probe
802        // is still in flight.
803        checker.last_check.clear();
804        checker.initiate(&backend_map, poll.registry());
805        assert_eq!(
806            checker.in_flight.len(),
807            2,
808            "the replacement must be probed without waiting for the removed incarnation's probe"
809        );
810        assert!(
811            std::ptr::eq(
812                checker.in_flight[1].backend.as_ptr(),
813                Rc::as_ptr(&replacement)
814            ),
815            "the new probe targets the replacement incarnation"
816        );
817    }
818}