dwara_core/dataplane/
active.rs

1//! Active health checks (DW-013, feature analysis 4.5).
2//!
3//! Passive health (DW-012) watches real traffic; active health PROBES. For
4//! every upstream that configures `active_health` (and therefore the
5//! passive `health` block), one probe loop task per endpoint runs:
6//!
7//! ```text
8//! loop { sleep(interval + uniform(0..jitter)); probe; report }
9//! ```
10//!
11//! # Probe mechanics
12//!
13//! Probes go DIRECTLY to the endpoint (`address:port`), bypassing load
14//! balancing and the pooled upstream client — a probe must examine one
15//! specific endpoint, not whoever the balancer would pick. Each `http`
16//! probe speaks HTTP/1.1 over its own connection (plaintext toward `http1`
17//! upstreams, rustls over the SAME trust roots as the pooled connector —
18//! webpki by default, the upstream's `trusted_ca_file` bundle when
19//! configured, #121 — with the endpoint address as server name toward
20//! `https`/`http2` upstreams), issues `GET {path}` with
21//! `Connection: close`, and classifies the FIRST status line it reads:
22//! 2xx = success; anything else (3xx included — redirects are not
23//! followed — 4xx, 5xx, truncation, timeout, transport error) = failure.
24//! `tcp` probes succeed when the TCP connect completes within
25//! `timeout_ms`. Documented limitation: `http2`-protocol upstreams that
26//! refuse HTTP/1.1 on a separate connection will read as failing; use
27//! `kind: tcp` for those.
28//!
29//! # Timing
30//!
31//! Each cycle sleeps `interval` + uniform(0..`jitter`) BEFORE probing, so
32//! the realized period is interval + jitter + probe duration and the tick
33//! drifts late by that sum per cycle (no fixed-wallclock scheduling); the
34//! jitter exists to keep probe storms from synchronizing across endpoints.
35//!
36//! # Reporting model (precedence over passive)
37//!
38//! Probe results report into the SAME per-endpoint
39//! [`crate::resilience::health::EndpointHealth`] tracker the passive
40//! checker and the load balancer use, so both systems feed one ejection
41//! state and the existing pick filter removes failing endpoints from
42//! rotation:
43//!
44//! - **Both run concurrently.** Either signal can eject: a passive report
45//!   ejects at the passive `consecutive_failures` threshold; an active
46//!   report ejects at `failure_threshold`. They share the tracker's
47//!   consecutive-failure streak, so the last report's parameters decide
48//!   whether that streak ejects — the coherent reading is "either can
49//!   eject, on its own threshold, from one shared streak".
50//! - **Active reports use the consecutive path only.** Probe reports go
51//!   through [`EndpointHealth::report_probe`], which never inserts an
52//!   observation into the rolling window — synthetic probes must not be
53//!   mixed into real-traffic ratios — and the probe's report parameters
54//!   additionally disable the failure-ratio/volume rule
55//!   (`failure_min_volume = u32::MAX`) as a second line of defense. The
56//!   passive window/eject/half-open parameters stay in force.
57//! - **Ejection** follows the passive model: after `failure_threshold`
58//!   consecutive failed probes the endpoint leaves rotation for
59//!   `health.eject_ms`. Probe failures while already ejected leave the
60//!   window alone (they cannot extend it).
61//! - **Recovery** is probe-driven: `success_threshold` consecutive
62//!   successful probes report a success, and a success observed on an
63//!   ejected (or half-open) tracker restores it outright — with active
64//!   health on, a healthy probe streak re-admits the endpoint even before
65//!   `eject_ms` expires, which is the point of probing. A probe success
66//!   below the threshold is reported only when the endpoint is currently
67//!   available (it just resets the failure streak; it never re-admits).
68//!   Best-effort under races, exactly like the rest of the tracker.
69//!
70//! # Task lifecycle
71//!
72//! [`ActiveProbes`] owns one [`tokio::task::JoinSet`] for every probe
73//! loop. The operator (dwara-bin) calls [`ActiveProbes::respawn`] after
74//! every snapshot swap (startup and each reload): all previous loops are
75//! aborted and new ones spawned for the new generation's upstreams.
76//! Endpoints whose `address:port` persists across the swap keep their
77//! tracker (the balancer carries it, DW-011/DW-012 semantics), so an
78//! ejection streak survives a reload even though the probe task restarts.
79//! Dropping [`ActiveProbes`] (or calling [`ActiveProbes::abort_all`])
80//! aborts every loop — the graceful-shutdown path.
81
82use std::future::Future;
83use std::sync::atomic::{AtomicU64, Ordering};
84use std::sync::Arc;
85use std::time::Duration;
86
87use bytes::Bytes;
88use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
89use tokio::task::JoinSet;
90use tokio::time::sleep;
91
92use crate::config::{ActiveHealth, ProbeKind};
93use crate::dataplane::balance::UpstreamLb;
94use crate::dataplane::upstream::UpstreamRegistry;
95use crate::resilience::health::{EndpointHealth, HealthParams};
96use crate::snapshot::Snapshot;
97
98/// Resolved (validated) active-probe parameters, copied into each probe
99/// loop so a config rebuild cannot change a running loop mid-flight.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct ActiveParams {
102    pub kind: ProbeKind,
103    pub path: String,
104    pub interval: Duration,
105    pub timeout: Duration,
106    pub success_threshold: u32,
107    pub failure_threshold: u32,
108    pub jitter: Duration,
109}
110
111impl ActiveParams {
112    /// Resolve from the config form (serde already applied defaults).
113    pub fn from_config(a: &ActiveHealth) -> Self {
114        ActiveParams {
115            kind: a.kind,
116            path: a.path.clone(),
117            interval: Duration::from_millis(a.interval_ms),
118            timeout: Duration::from_millis(a.timeout_ms),
119            success_threshold: a.success_threshold,
120            failure_threshold: a.failure_threshold,
121            jitter: Duration::from_millis(a.jitter_ms),
122        }
123    }
124}
125
126/// Report parameters for probe outcomes: the passive window/eject/half-open
127/// parameters with the ACTIVE thresholds substituted and the ratio rule
128/// disabled (see the module docs).
129///
130/// Public for testing the probe report-parameters contract.
131pub fn report_params(active: &ActiveParams, passive: &HealthParams) -> HealthParams {
132    HealthParams {
133        window_ms: passive.window_ms,
134        consecutive_failures: active.failure_threshold.max(1),
135        failure_ratio: 1.0,
136        failure_min_volume: u32::MAX,
137        eject_ms: passive.eject_ms,
138        half_open_probes: passive.half_open_probes,
139        recovery_ramp_ms: passive.recovery_ramp_ms,
140    }
141}
142
143/// `address:port` with IPv6 literals bracketed (same rule as the upstream
144/// client).
145fn authority(address: &str, port: u16) -> String {
146    let host = if address.parse::<std::net::Ipv6Addr>().is_ok() {
147        format!("[{address}]")
148    } else {
149        address.to_string()
150    };
151    format!("{host}:{port}")
152}
153
154/// Read the response status line over `io` and classify it. Reads
155/// accumulate until CRLF is seen (a status line may arrive fragmented
156/// across segments), the 4 KB cap is hit, or the peer closes; the outer
157/// probe timeout bounds the whole loop (a stalled peer simply never gets
158/// a verdict before the attempt future is dropped = failure).
159async fn http_status_ok<S>(io: &mut S, authority: &str, path: &str) -> std::io::Result<bool>
160where
161    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
162{
163    let req = format!(
164        "GET {path} HTTP/1.1\r\nHost: {authority}\r\nUser-Agent: dwara-probe\r\n\
165         Connection: close\r\n\r\n"
166    );
167    io.write_all(req.as_bytes()).await?;
168    io.flush().await?;
169    const CAP: usize = 4096;
170    let mut buf: Vec<u8> = Vec::with_capacity(1024);
171    let mut chunk = [0u8; 1024];
172    loop {
173        let n = io.read(&mut chunk).await?;
174        if n == 0 {
175            break; // closed: parse whatever arrived
176        }
177        buf.extend_from_slice(&chunk[..n]);
178        if buf.len() >= CAP || buf.windows(2).any(|w| w == b"\r\n") {
179            break;
180        }
181    }
182    if buf.is_empty() {
183        return Ok(false); // closed before any status line
184    }
185    let head = String::from_utf8_lossy(&buf);
186    let status = head
187        .lines()
188        .next()
189        .and_then(|l| l.split_whitespace().nth(1))
190        .and_then(|s| s.parse::<u16>().ok());
191    Ok(matches!(status, Some(s) if (200..300).contains(&s)))
192}
193
194/// One direct probe of `address:port`, bounded by `timeout` (connect plus
195/// response for http). `tls` is the upstream's outbound TLS client config:
196/// Some for `https`/`http2` upstreams (built from the SAME root store as
197/// the pooled connector — webpki by default, the upstream's
198/// `trusted_ca_file` bundle when configured, #121 — with HTTP/1.1 ALPN),
199/// None for plaintext `http1` upstreams. `happy` is the upstream's
200/// resolved happy-eyeballs delay (DW-030): probes dial through the same
201/// RFC 8305 racing as the pooled connector, so a dual-stack endpoint's
202/// probe does not silently behave differently from its traffic.
203/// Returns whether the endpoint answered healthy.
204pub async fn probe_once(
205    kind: ProbeKind,
206    tls: Option<&Arc<rustls::ClientConfig>>,
207    address: &str,
208    port: u16,
209    path: &str,
210    timeout: Duration,
211    happy: Option<Duration>,
212) -> bool {
213    match kind {
214        ProbeKind::Tcp => tokio::time::timeout(
215            timeout,
216            crate::dataplane::upstream::happy_dial(address, port, happy),
217        )
218        .await
219        .is_ok_and(|r| r.is_ok()),
220        ProbeKind::Http => {
221            let attempt = async {
222                let tcp = crate::dataplane::upstream::happy_dial(address, port, happy).await?;
223                match tls {
224                    None => {
225                        let mut io = tcp;
226                        let ok = http_status_ok(&mut io, &authority(address, port), path).await?;
227                        let _ = io.shutdown().await;
228                        Ok::<bool, std::io::Error>(ok)
229                    }
230                    Some(cfg) => {
231                        // Same trust as the pooled client; endpoint address
232                        // as server name (works for hostname endpoints and
233                        // IP endpoints with IP-SAN certificates).
234                        let connector = tokio_rustls::TlsConnector::from(Arc::clone(cfg));
235                        let name =
236                            match rustls::pki_types::ServerName::try_from(address.to_string()) {
237                                Ok(n) => n,
238                                Err(_) => return Ok(false),
239                            };
240                        let mut tls = connector.connect(name, tcp).await?;
241                        let ok = http_status_ok(&mut tls, &authority(address, port), path).await?;
242                        let _ = tls.shutdown().await;
243                        Ok(ok)
244                    }
245                }
246            };
247            tokio::time::timeout(timeout, attempt)
248                .await
249                .map(|r| r.unwrap_or(false))
250                .unwrap_or(false)
251        }
252        ProbeKind::Http2 => {
253            // REL-07 (#218): HTTP/2 probe for H2-only/gRPC upstreams.
254            // Uses hyper's H2 client with ALPN negotiation. The TLS
255            // config (when present) is cloned with h2 ALPN so the
256            // handshake negotiates HTTP/2; plaintext H2c is also
257            // supported via the prior-knowledge path.
258            let attempt = async {
259                let tcp = crate::dataplane::upstream::happy_dial(address, port, happy).await?;
260                match tls {
261                    None => {
262                        // Plaintext H2c (prior knowledge).
263                        let io = hyper_util::rt::TokioIo::new(tcp);
264                        let (sender, conn) = hyper::client::conn::http2::handshake(
265                            hyper_util::rt::TokioExecutor::new(),
266                            io,
267                        )
268                        .await
269                        .map_err(|e| std::io::Error::other(e.to_string()))?;
270                        tokio::spawn(conn);
271                        http2_status_ok(sender, &authority(address, port), path).await
272                    }
273                    Some(cfg) => {
274                        // TLS with h2 ALPN.
275                        let mut h2_cfg = (**cfg).clone();
276                        h2_cfg.alpn_protocols = vec![b"h2".to_vec()];
277                        let connector = tokio_rustls::TlsConnector::from(Arc::new(h2_cfg));
278                        let name =
279                            match rustls::pki_types::ServerName::try_from(address.to_string()) {
280                                Ok(n) => n,
281                                Err(_) => return Ok(false),
282                            };
283                        let tls = connector.connect(name, tcp).await?;
284                        let io = hyper_util::rt::TokioIo::new(tls);
285                        let (sender, conn) = hyper::client::conn::http2::handshake(
286                            hyper_util::rt::TokioExecutor::new(),
287                            io,
288                        )
289                        .await
290                        .map_err(|e| std::io::Error::other(e.to_string()))?;
291                        tokio::spawn(conn);
292                        http2_status_ok(sender, &authority(address, port), path).await
293                    }
294                }
295            };
296            tokio::time::timeout(timeout, attempt)
297                .await
298                .map(|r| r.unwrap_or(false))
299                .unwrap_or(false)
300        }
301    }
302}
303
304/// Send a GET request over an H2 connection and classify the status
305/// (REL-07, #218). Mirrors `http_status_ok`: 2xx = success.
306async fn http2_status_ok(
307    mut sender: hyper::client::conn::http2::SendRequest<http_body_util::Empty<Bytes>>,
308    authority: &str,
309    path: &str,
310) -> Result<bool, std::io::Error> {
311    let req = hyper::Request::builder()
312        .method(hyper::Method::GET)
313        .uri(format!("https://{authority}{path}"))
314        .header(hyper::header::HOST, authority)
315        .header(hyper::header::USER_AGENT, "dwara-probe")
316        .body(http_body_util::Empty::<Bytes>::new())
317        .map_err(|e| std::io::Error::other(e.to_string()))?;
318    let resp = sender
319        .send_request(req)
320        .await
321        .map_err(|e| std::io::Error::other(e.to_string()))?;
322    let status = resp.status().as_u16();
323    Ok((200..300).contains(&status))
324}
325
326/// DW-108: QUIC active health probe for `protocol: h3` upstreams. Opens
327/// a one-off QUIC connection to `address:port` (verifying the upstream's
328/// server certificate against `tls`, the SAME h3-ALPN rustls config the
329/// pooled H3 connector uses), sends a HEAD request for `path`, and
330/// classifies the response status: 2xx = healthy, anything else
331/// (including connect/transport/timeout failure) = unhealthy. Mirrors
332/// [`probe_once`]'s `http` classification (the first status wins;
333/// redirects are not followed) so an H3 upstream's active-health
334/// semantics match its H1/H2 counterparts. The QUIC stack is always
335/// linked; `respawn` routes to this when an H3 upstream is configured.
336async fn probe_once_h3(
337    tls: &Arc<rustls::ClientConfig>,
338    address: &str,
339    port: u16,
340    path: &str,
341    timeout: Duration,
342) -> bool {
343    use bytes::Buf as _;
344    let attempt = async {
345        let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(Arc::clone(tls))
346            .map_err(|e| std::io::Error::other(e.to_string()))?;
347        let client_cfg = quinn::ClientConfig::new(Arc::new(quic_cfg));
348        let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse().expect("valid addr"))
349            .map_err(|e| std::io::Error::other(e.to_string()))?;
350        endpoint.set_default_client_config(client_cfg);
351        let addr = tokio::net::lookup_host((address, port))
352            .await
353            .map_err(|e| std::io::Error::other(e.to_string()))?
354            .next()
355            .ok_or_else(|| {
356                std::io::Error::new(std::io::ErrorKind::AddrNotAvailable, "no addresses")
357            })?;
358        let conn = endpoint
359            .connect(addr, address)
360            .map_err(|e| std::io::Error::other(e.to_string()))?
361            .await
362            .map_err(|e| std::io::Error::other(e.to_string()))?;
363        let (h3_conn, mut send_req) = h3::client::new(h3_quinn::Connection::new(conn))
364            .await
365            .map_err(|e| std::io::Error::other(e.to_string()))?;
366        // Drive h3 control frames for the lifetime of this probe; the
367        // task resolves when the connection closes (the sender drops
368        // after the request, draining the connection).
369        let _driver = tokio::spawn(async move {
370            let mut h3_conn = h3_conn;
371            let _ = h3_conn.wait_idle().await;
372        });
373        let req = http::Request::builder()
374            .method(http::Method::HEAD)
375            .uri(format!("https://{address}{path}"))
376            .body(())
377            .expect("static builder");
378        let mut stream = send_req
379            .send_request(req)
380            .await
381            .map_err(|e| std::io::Error::other(e.to_string()))?;
382        stream
383            .finish()
384            .await
385            .map_err(|e| std::io::Error::other(e.to_string()))?;
386        let resp = stream
387            .recv_response()
388            .await
389            .map_err(|e| std::io::Error::other(e.to_string()))?;
390        let status = resp.status().as_u16();
391        // Drain any body the server sends (HEAD should be empty, but be
392        // robust) so the stream closes cleanly.
393        while let Some(mut chunk) = stream
394            .recv_data()
395            .await
396            .map_err(|e| std::io::Error::other(e.to_string()))?
397        {
398            let _ = chunk.copy_to_bytes(chunk.remaining());
399        }
400        let _ = stream.recv_trailers().await;
401        Ok::<bool, std::io::Error>((200..300).contains(&status))
402    };
403    tokio::time::timeout(timeout, attempt)
404        .await
405        .map(|r| r.unwrap_or(false))
406        .unwrap_or(false)
407}
408
409// Process-local xorshift64* seed for full jitter (no `rand` dependency;
410// same approach as the balancer's random-2).
411static JITTER_RNG: AtomicU64 = AtomicU64::new(0x853c_49e6_748f_ea9b);
412
413/// Public for testing the probe-jitter bound contract.
414pub fn next_below(bound_ms: u64) -> u64 {
415    if bound_ms == 0 {
416        return 0;
417    }
418    loop {
419        let x = JITTER_RNG.load(Ordering::Relaxed);
420        let mut y = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
421        y ^= y >> 12;
422        y ^= y << 25;
423        y ^= y >> 27;
424        if JITTER_RNG
425            .compare_exchange(x, y, Ordering::Relaxed, Ordering::Relaxed)
426            .is_ok()
427        {
428            // Multiply-shift reduction (Lemire-style): unbiased enough for
429            // jitter at these widths.
430            return ((y as u128 * bound_ms as u128) >> 64) as u64;
431        }
432    }
433}
434
435/// Give up on a probe loop after this many consecutive panicked
436/// iterations; the endpoint keeps its passive tracker (passive-only
437/// health) and the operator sees a loud log.
438const MAX_CONSECUTIVE_PANICS: u32 = 3;
439
440/// `catch_unwind` for futures (std has no `FutureExt::catch_unwind`):
441/// each poll of `fut` runs under `catch_unwind`, so a panic raised at any
442/// await point inside `fut` surfaces as `Err` instead of unwinding the
443/// loop task.
444pub fn catch_unwind_future<F: Future>(
445    fut: F,
446) -> impl Future<Output = Result<F::Output, Box<dyn std::any::Any + Send>>> {
447    let mut fut = Box::pin(fut);
448    std::future::poll_fn(move |cx| {
449        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fut.as_mut().poll(cx))) {
450            Ok(poll) => poll.map(Ok),
451            Err(panic) => std::task::Poll::Ready(Err(panic)),
452        }
453    })
454}
455
456/// The per-endpoint probe loop. Runs until the task is aborted by a
457/// respawn or shutdown; every iteration reports into `tracker` via
458/// [`EndpointHealth::report_probe`] (probe results never enter the
459/// passive ratio window).
460///
461/// A panicking iteration must not kill the loop silently: each iteration
462/// body runs under [`catch_unwind_future`], a panic is logged loudly, and
463/// the loop continues after a backoff of one `interval`. After
464/// [`MAX_CONSECUTIVE_PANICS`] consecutive panics the loop gives up
465/// (logged) and the endpoint stays passive-only until the next respawn.
466// Fixed probe plumbing; DW-030 added the happy-eyeballs delay.
467#[allow(clippy::too_many_arguments)]
468async fn probe_loop(
469    lb: Arc<UpstreamLb>,
470    address: String,
471    port: u16,
472    tracker: Arc<EndpointHealth>,
473    active: ActiveParams,
474    passive: HealthParams,
475    tls: Option<Arc<rustls::ClientConfig>>,
476    happy: Option<Duration>,
477    // DW-108: when Some, the upstream is `protocol: h3` and the probe
478    // dials QUIC with this h3-ALPN rustls config. None for H1/H2/h2c
479    // upstreams. Only set when the `h3` cargo feature is on.
480    h3_tls: Option<Arc<rustls::ClientConfig>>,
481) {
482    let report = report_params(&active, &passive);
483    let mut success_streak: u32 = 0;
484    let mut consecutive_panics: u32 = 0;
485    loop {
486        sleep(active.interval).await;
487        let jitter = Duration::from_millis(next_below(active.jitter.as_millis() as u64));
488        sleep(jitter).await;
489        let iteration = async {
490            // DW-108: H3 upstreams probe over QUIC; the rest use the
491            // TCP/TLS probe. The h3 branch is feature-gated; when the
492            // feature is off `h3_tls` is never Some (respawn skips H3
493            // upstreams), so the unreachable `false` arm never runs.
494            let ok = if let Some(_h3_cfg) = &h3_tls {
495                {
496                    probe_once_h3(_h3_cfg, &address, port, &active.path, active.timeout).await
497                }
498            } else {
499                probe_once(
500                    active.kind,
501                    tls.as_ref(),
502                    &address,
503                    port,
504                    &active.path,
505                    active.timeout,
506                    happy,
507                )
508                .await
509            };
510            let now = lb.now_ms();
511            if ok {
512                success_streak += 1;
513                // Below the success threshold a success only matters when
514                // the endpoint is already available (reset the shared
515                // failure streak); re-admission needs the full threshold.
516                if success_streak >= active.success_threshold || tracker.is_available(now) {
517                    tracker.report_probe(&report, now, false);
518                }
519            } else {
520                success_streak = 0;
521                tracker.report_probe(&report, now, true);
522            }
523        };
524        match catch_unwind_future(iteration).await {
525            Ok(()) => consecutive_panics = 0,
526            Err(panic) => {
527                consecutive_panics += 1;
528                let detail = panic
529                    .downcast_ref::<&str>()
530                    .map(|s| (*s).to_string())
531                    .or_else(|| panic.downcast_ref::<String>().cloned())
532                    .unwrap_or_else(|| "unknown panic".to_string());
533                tracing::error!(
534                    code = "active_probe_panicked",
535                    endpoint = %format!("{address}:{port}"),
536                    "active probe loop panicked ({consecutive_panics}/{MAX_CONSECUTIVE_PANICS}): {detail}"
537                );
538                if consecutive_panics >= MAX_CONSECUTIVE_PANICS {
539                    tracing::error!(
540                        code = "active_probe_abandoned",
541                        endpoint = %format!("{address}:{port}"),
542                        "active probe loop giving up after {MAX_CONSECUTIVE_PANICS} \
543                         consecutive panics; passive health continues for this endpoint"
544                    );
545                    return;
546                }
547                success_streak = 0;
548                sleep(active.interval).await; // backoff before the retry
549            }
550        }
551    }
552}
553
554/// Owns every active-probe loop task for the running generation. Call
555/// [`ActiveProbes::respawn`] on startup and after every snapshot swap;
556/// dropping it (or shutdown) aborts all loops.
557#[derive(Default)]
558pub struct ActiveProbes {
559    tasks: JoinSet<()>,
560}
561
562impl ActiveProbes {
563    pub fn new() -> Self {
564        Self::default()
565    }
566
567    /// Abort all current probe loops and spawn fresh ones for every
568    /// upstream in `snapshot` that configures `active_health`. Trackers are
569    /// taken from the CURRENT registry generation (call after
570    /// `DataPlane::refresh`), so loops probe the same live tracker the
571    /// balancer filters with. Upstreams without active health spawn
572    /// nothing.
573    pub fn respawn(&mut self, registry: &UpstreamRegistry, snapshot: &Snapshot) {
574        self.abort_all();
575        for u in &snapshot.gateway().upstreams {
576            let Some(active_cfg) = &u.active_health else {
577                continue;
578            };
579            // Validation guarantees health.is_some(); a directly
580            // constructed (unvalidated) pair simply does not probe.
581            let Some(passive_cfg) = &u.health else {
582                continue;
583            };
584            let Some(handle) = registry.get(&u.name) else {
585                continue;
586            };
587            let active = ActiveParams::from_config(active_cfg);
588            let passive = HealthParams::from_config(passive_cfg);
589            // Same trust as the pooled connector (#121): the probe client
590            // config is built from the HANDLE's root store, so an upstream
591            // with a `trusted_ca_file` bundle is probed over https with
592            // exactly the trust its proxied traffic uses. ALPN stays
593            // http/1.1 here regardless of the upstream's protocol (a probe
594            // is always a plain HTTP/1.1 exchange; see the module docs for
595            // the http2 caveat).
596            let tls = handle
597                .tls_roots()
598                .map(|roots| Arc::new(crate::security::tls::https_h1_client_config(roots.clone())));
599            for (address, port, tracker) in handle.lb().health_targets() {
600                let Some(tracker) = tracker else { continue };
601                self.tasks.spawn(probe_loop(
602                    Arc::clone(handle.lb()),
603                    address,
604                    port,
605                    tracker,
606                    active.clone(),
607                    passive,
608                    tls.clone(),
609                    handle.happy_eyeballs(),
610                    // DW-108: H3 upstreams probe over QUIC; pass the H3
611                    // TLS config when the feature is on, None otherwise.
612                    handle.h3_handle().map(|h| Arc::clone(h.tls_config())),
613                ));
614            }
615        }
616    }
617
618    /// Abort every probe loop (they stop at the next await point).
619    pub fn abort_all(&mut self) {
620        self.tasks.abort_all();
621    }
622
623    /// Number of live probe-loop tasks (spawned minus finished/aborted
624    /// that have been reaped). Observability/tests.
625    pub fn task_count(&mut self) -> usize {
626        while self.tasks.try_join_next().is_some() {}
627        self.tasks.len()
628    }
629}