use std::collections::VecDeque;
use std::net::SocketAddr;
use anyhow::{Context, Result, bail};
use tokio::net::UdpSocket;
use tokio::sync::{mpsc, watch};
use tracing::{debug, info, warn};
use rtime_core::source::{SourceId, SourceMeasurement};
use rtime_core::timestamp::NtpTimestamp;
use rtime_metrics::instruments;
use rtime_ntp::client;
use rtime_ntp::packet::{NTP_HEADER_SIZE, NtpPacket};
const INITIAL_BURST_COUNT: u32 = 4;
const INITIAL_BURST_INTERVAL_SECS: u64 = 8;
const RECV_TIMEOUT_SECS: u64 = 5;
pub async fn run_ntp_client(
server_addr: SocketAddr,
measurement_tx: mpsc::Sender<SourceMeasurement>,
mut shutdown: watch::Receiver<bool>,
metrics_enabled: bool,
min_poll: i8,
max_poll: i8,
) -> Result<()> {
let min_poll = min_poll.clamp(1, 17);
let max_poll = max_poll.clamp(min_poll, 17);
let normal_poll_secs: u64 = 1u64 << (min_poll as u32);
let socket = UdpSocket::bind("0.0.0.0:0")
.await
.context("failed to bind NTP client UDP socket")?;
info!(
"NTP client started for {} (poll interval: {}s-{}s)",
server_addr,
1u64 << (min_poll as u32),
1u64 << (max_poll as u32),
);
let mut query_count: u64 = 0;
let mut jitter_samples: VecDeque<f64> = VecDeque::new();
let mut last_offset_ms: Option<f64> = None;
let peer_label = server_addr.to_string();
loop {
let interval_secs = if query_count < INITIAL_BURST_COUNT as u64 {
INITIAL_BURST_INTERVAL_SECS
} else {
normal_poll_secs
};
match query_server(&socket, server_addr).await {
Ok(result) => {
let offset_ms = result.offset.to_millis_f64();
let delay_ms = result.delay.to_millis_f64();
debug!(
"NTP response from {}: offset={:+.3}ms delay={:.3}ms stratum={}",
server_addr, offset_ms, delay_ms, result.stratum,
);
if metrics_enabled {
instruments::increment_ntp_packets_sent();
instruments::increment_ntp_packets_received();
instruments::record_ntp_source_offset(
&peer_label,
result.offset.to_seconds_f64(),
);
instruments::record_ntp_source_delay(
&peer_label,
result.delay.to_seconds_f64(),
);
}
let jitter = if let Some(prev) = last_offset_ms {
let diff = offset_ms - prev;
jitter_samples.push_back(diff * diff);
if jitter_samples.len() > 8 {
jitter_samples.pop_front();
}
let sum: f64 = jitter_samples.iter().sum();
(sum / jitter_samples.len() as f64).sqrt()
} else {
0.0
};
last_offset_ms = Some(offset_ms);
let measurement = SourceMeasurement {
id: SourceId::Ntp {
address: server_addr,
reference_id: result.reference_id,
},
offset: result.offset,
delay: result.delay,
dispersion: result.root_dispersion,
jitter,
stratum: result.stratum,
leap_indicator: result.leap_indicator,
root_delay: result.root_delay,
root_dispersion: result.root_dispersion,
time: NtpTimestamp::now(),
};
if let Err(e) = measurement_tx.send(measurement).await {
warn!("Failed to send measurement (receiver dropped): {}", e);
break;
}
query_count += 1;
}
Err(e) => {
warn!("NTP query to {} failed: {}", server_addr, e);
if metrics_enabled {
instruments::increment_ntp_packets_sent();
instruments::increment_ntp_packets_dropped();
}
}
}
tokio::select! {
_ = tokio::time::sleep(tokio::time::Duration::from_secs(interval_secs)) => {
}
result = shutdown.changed() => {
if result.is_ok() && *shutdown.borrow() {
info!("NTP client for {} shutting down", server_addr);
break;
}
}
}
}
Ok(())
}
async fn query_server(socket: &UdpSocket, addr: SocketAddr) -> Result<client::NtpResult> {
let cookie = NtpTimestamp::now();
let request = client::build_request(cookie);
let request_bytes = request.serialize();
let t1 = NtpTimestamp::now();
socket
.send_to(&request_bytes, addr)
.await
.context("failed to send NTP request")?;
let mut buf = [0u8; 512];
let (len, _from) = tokio::time::timeout(
tokio::time::Duration::from_secs(RECV_TIMEOUT_SECS),
socket.recv_from(&mut buf),
)
.await
.context("NTP response timeout")?
.context("failed to receive NTP response")?;
let t4 = NtpTimestamp::now();
if len < NTP_HEADER_SIZE {
bail!("NTP response too short: {} bytes", len);
}
let response = NtpPacket::parse(&buf[..len]).context("failed to parse NTP response")?;
let result = client::process_response(&response, t1, t4, cookie)
.context("failed to process NTP response")?;
Ok(result)
}