use std::net::TcpStream;
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use bytes::Bytes;
use foundation_core::synca::OnSignal;
use foundation_core::url::Uri;
use foundation_core::valtron::{block_on_future, sleep_async};
use foundation_netio::shared::http::{
Proto, RequestDescriptor, SimpleHeader, SimpleHeaders, SimpleMethod, SimpleUrl,
};
use foundation_connectrpc::{
CancelSignal, ClientExchange, ClientExchangeConfig, GrpcClient, H2Transport, ProtocolClient,
Spec, Transport,
};
use futures_util::future::{select, Either};
use futures_util::FutureExt;
use crate::config::{BackendProtocol, HealthCheckConfig};
use crate::forward::SharedHttpClient;
use crate::runtime::{BackendRuntime, ServiceRuntime};
#[derive(Debug, Clone)]
pub struct ProbeState {
healthy_threshold: u32,
unhealthy_threshold: u32,
consecutive_successes: u32,
consecutive_failures: u32,
healthy: bool,
}
impl ProbeState {
#[must_use]
pub fn new(healthy_threshold: u32, unhealthy_threshold: u32, initial_healthy: bool) -> Self {
Self {
healthy_threshold: healthy_threshold.max(1),
unhealthy_threshold: unhealthy_threshold.max(1),
consecutive_successes: 0,
consecutive_failures: 0,
healthy: initial_healthy,
}
}
#[must_use]
pub fn is_healthy(&self) -> bool {
self.healthy
}
pub fn record(&mut self, success: bool) -> Option<bool> {
if success {
self.consecutive_failures = 0;
self.consecutive_successes = self.consecutive_successes.saturating_add(1);
if !self.healthy && self.consecutive_successes >= self.healthy_threshold {
self.healthy = true;
return Some(true);
}
} else {
self.consecutive_successes = 0;
self.consecutive_failures = self.consecutive_failures.saturating_add(1);
if self.healthy && self.consecutive_failures >= self.unhealthy_threshold {
self.healthy = false;
return Some(false);
}
}
None
}
}
#[derive(Debug)]
pub struct HealthMonitor {
handles: Vec<JoinHandle<()>>,
}
impl HealthMonitor {
pub fn join(self) {
for handle in self.handles {
let _ = handle.join();
}
}
}
#[must_use]
pub fn spawn_service_probes(
service: &Arc<ServiceRuntime>,
client: SharedHttpClient,
shutdown: Arc<OnSignal>,
) -> HealthMonitor {
let Some(config) = service.config().health_check.clone() else {
return HealthMonitor { handles: Vec::new() };
};
let mut handles = Vec::new();
for backend in service.backends() {
let backend = Arc::clone(backend);
let config = config.clone();
let client = client.clone();
let shutdown = Arc::clone(&shutdown);
let handle = std::thread::spawn(move || {
probe_loop(&backend, &config, &client, &shutdown);
});
handles.push(handle);
}
HealthMonitor { handles }
}
fn probe_loop(
backend: &Arc<BackendRuntime>,
config: &HealthCheckConfig,
client: &SharedHttpClient,
shutdown: &Arc<OnSignal>,
) {
let mut state = ProbeState::new(
config.healthy_threshold,
config.unhealthy_threshold,
backend.is_healthy(),
);
loop {
if shutdown.probe() {
return;
}
let success = probe_once(backend, config, client);
if let Some(now_healthy) = state.record(success) {
backend.set_healthy(now_healthy);
if now_healthy {
tracing::info!(url = %backend.url(), "backend became healthy");
} else {
tracing::warn!(url = %backend.url(), "backend became unhealthy");
}
}
if !sleep_interruptible(config.interval, shutdown) {
return;
}
}
}
fn probe_once(
backend: &Arc<BackendRuntime>,
config: &HealthCheckConfig,
client: &SharedHttpClient,
) -> bool {
match backend.protocol() {
BackendProtocol::Tcp => probe_tcp(&backend.target().authority(), config.timeout),
BackendProtocol::Udp => probe_udp(&backend.target().authority(), config.timeout),
BackendProtocol::Grpc => probe_grpc_health(backend.url(), config.timeout),
BackendProtocol::Http
| BackendProtocol::Https
| BackendProtocol::Connect
| BackendProtocol::GrpcWeb => {
probe_http(backend.url(), &config.path, config.timeout, client)
}
}
}
fn probe_tcp(authority: &str, timeout: Duration) -> bool {
match authority.to_socket_addrs_first() {
Some(addr) => TcpStream::connect_timeout(&addr, timeout).is_ok(),
None => TcpStream::connect(authority).is_ok(),
}
}
fn probe_udp(authority: &str, timeout: Duration) -> bool {
use std::net::UdpSocket;
let Ok(socket) = UdpSocket::bind("0.0.0.0:0") else {
return false;
};
let _ = socket.set_read_timeout(Some(timeout));
if socket.send_to(&[1u8], authority).is_err() {
return false;
}
let mut buf = [0u8; 16];
socket.recv_from(&mut buf).is_ok()
}
fn probe_http(base_url: &str, path: &str, timeout: Duration, client: &SharedHttpClient) -> bool {
let url = join_url(base_url, path);
let probe_client = client
.clone()
.connect_timeout(timeout)
.read_timeout(timeout);
let Ok(builder) = probe_client.get(&url) else {
return false;
};
let Ok(request) = probe_client.request(builder) else {
return false;
};
match request.send() {
Ok(resp) => {
let code = resp.get_status().into_usize();
(200..400).contains(&code)
}
Err(err) => {
tracing::trace!(%url, ?err, "health probe request failed");
false
}
}
}
const SERVING: u64 = 0;
fn decode_health_status(msg: &[u8]) -> u64 {
let mut i = 0;
while i < msg.len() {
let tag = varint(msg, &mut i);
match tag & 0x07 {
0 => {
let value = varint(msg, &mut i);
if tag >> 3 == 1 {
return value;
}
}
2 => {
let len = varint(msg, &mut i) as usize;
i = i.saturating_add(len);
}
5 => i = i.saturating_add(4), 1 => i = i.saturating_add(8), _ => return SERVING,
}
}
SERVING
}
fn varint(buf: &[u8], i: &mut usize) -> u64 {
let mut result = 0u64;
let mut shift = 0u32;
loop {
let Some(&byte) = buf.get(*i) else {
break;
};
*i += 1;
result |= u64::from(byte & 0x7f) << shift;
if byte & 0x80 == 0 {
break;
}
shift += 7;
if shift >= 64 {
break;
}
}
result
}
async fn grpc_health_check(url: String, timeout: Duration) -> Result<bool, String> {
let uri = Uri::parse(&url).map_err(|e| format!("parse health uri {url}: {e}"))?;
let mut headers = SimpleHeaders::new();
headers.insert(
SimpleHeader::CONTENT_TYPE,
vec!["application/grpc".to_string()],
);
headers.insert(
SimpleHeader::from("te".to_string()),
vec!["trailers".to_string()],
);
let out_request = RequestDescriptor {
proto: Proto::HTTP20,
request_url: SimpleUrl::url_only(&url),
request_uri: uri,
headers,
method: SimpleMethod::POST,
};
let stream = H2Transport::new()
.open(out_request)
.map_err(|e| format!("open h2 health upstream {url}: {e}"))?;
let outbound = GrpcClient
.new_conn(
&Spec::empty(true),
SimpleHeaders::new(),
stream,
ClientExchangeConfig {
cancel: CancelSignal::new(),
read_max_bytes: 0,
},
)
.map_err(|e| format!("build grpc health exchange: {e}"))?;
let ClientExchange {
conn,
reader_task,
writer_task,
} = outbound;
let (mut out_send, mut out_recv) = conn.split();
let result: Arc<std::sync::Mutex<Option<Result<bool, String>>>> =
Arc::new(std::sync::Mutex::new(None));
let sender = async move {
let _ = out_send.send(Bytes::new()).await;
let _ = out_send.close_send().await;
};
let result_ref = Arc::clone(&result);
let receiver = async move {
let outcome = async {
let mut msg: Option<Bytes> = None;
loop {
match out_recv.receive().await {
Ok(Some(bytes)) => msg = Some(bytes),
Ok(None) => break,
Err(e) => return Err(format!("health receive failed: {e}")),
}
}
let trailers = out_recv
.response_trailers()
.await
.map(|t| t.clone())
.unwrap_or_default();
let grpc_status = trailers
.get(&SimpleHeader::GRPC_STATUS)
.and_then(|v| v.first())
.map(|s| s.as_str());
if grpc_status != Some("0") {
return Err(format!("health check grpc-status {grpc_status:?}"));
}
Ok(decode_health_status(msg.as_deref().unwrap_or(b"")) == SERVING)
}
.await;
*result_ref.lock().unwrap() = Some(outcome);
};
let work = futures_util::future::join_all(vec![
sender.boxed(),
receiver.boxed(),
reader_task.map(|_| ()).boxed(),
writer_task.map(|_| ()).boxed(),
])
.boxed();
let timer = async move { sleep_async(timeout).await }.boxed();
match select(work, timer).await {
Either::Left(_) => result
.lock()
.unwrap()
.take()
.unwrap_or_else(|| Err("gRPC health check produced no result".to_string())),
Either::Right(_) => Err("gRPC health check timed out".to_string()),
}
}
fn probe_grpc_health(base_url: &str, timeout: Duration) -> bool {
let url = join_url(base_url, "/grpc.health.v1.Health/Check");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(block_on_future(grpc_health_check(url, timeout)));
});
rx.recv_timeout(timeout + Duration::from_millis(250))
.ok()
.and_then(std::result::Result::ok)
.unwrap_or(false)
}
fn join_url(base_url: &str, path: &str) -> String {
let base = base_url.strip_suffix('/').unwrap_or(base_url);
if path.starts_with('/') {
format!("{base}{path}")
} else {
format!("{base}/{path}")
}
}
fn sleep_interruptible(total: Duration, shutdown: &Arc<OnSignal>) -> bool {
let slice = Duration::from_millis(100);
let start = Instant::now();
while start.elapsed() < total {
if shutdown.probe() {
return false;
}
std::thread::sleep(slice.min(total.saturating_sub(start.elapsed())));
}
true
}
trait FirstSocketAddr {
fn to_socket_addrs_first(&self) -> Option<std::net::SocketAddr>;
}
impl FirstSocketAddr for str {
fn to_socket_addrs_first(&self) -> Option<std::net::SocketAddr> {
use std::net::ToSocketAddrs;
self.to_socket_addrs().ok().and_then(|mut it| it.next())
}
}