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}