This commit is contained in:
2026-05-03 16:24:47 +02:00
commit 47c33a3a6c
35 changed files with 7288 additions and 0 deletions
+230
View File
@@ -0,0 +1,230 @@
//! Métrologie du serveur UDP.
//!
//! Ce module expose :
//! - [`UdpMetrics`] : compteurs atomiques lock-free (pas de contention dans la
//! boucle de routage).
//! - [`UdpMetricsSnapshot`] : lecture cohérente de tous les compteurs à un
//! instant T, utilisable pour calculer des deltas.
//! - [`UdpRates`] : taux moyens par seconde calculés entre deux snapshots.
//! - [`spawn_reporter`] : tâche tokio de reporting périodique via `tracing`.
//!
//! # Métriques collectées
//!
//! | Compteur | Description |
//! |--------------------|-----------------------------------------------|
//! | `packets_received` | Datagrammes reçus |
//! | `bytes_received` | Octets reçus (payload uniquement) |
//! | `packets_sent` | Datagrammes retransmis vers des abonnés |
//! | `bytes_sent` | Octets retransmis |
//! | `packets_dropped` | Paquets ignorés (canal sans abonnés) |
//! | `send_errors` | Échecs `send_to` |
//! | `recv_errors` | Échecs `recv_from` (avant erreur fatale) |
//!
//! Chaque métrique est également disponible en taux moyen par seconde via
//! [`UdpMetricsSnapshot::rates_since`].
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
// ── Compteurs ────────────────────────────────────────────────────────────────
/// Compteurs atomiques du serveur UDP.
///
/// Partagé via [`Arc`] entre la boucle de routage et le reporter périodique.
/// Tous les accès utilisent [`Ordering::Relaxed`] : on accepte que les lectures
/// voient des valeurs légèrement décalées entre compteurs (suffisant pour de la
/// métrologie), ce qui évite tout overhead de synchronisation.
#[derive(Debug, Default)]
pub struct UdpMetrics {
/// Nombre total de datagrammes reçus.
pub packets_received: AtomicU64,
/// Volume total d'octets reçus (payload des datagrammes).
pub bytes_received: AtomicU64,
/// Nombre total de datagrammes retransmis (somme sur tous les abonnés).
pub packets_sent: AtomicU64,
/// Volume total d'octets retransmis.
pub bytes_sent: AtomicU64,
/// Paquets ignorés car le canal ne possède aucun abonné.
pub packets_dropped: AtomicU64,
/// Nombre d'erreurs `send_to` (non fatales).
pub send_errors: AtomicU64,
/// Nombre d'erreurs `recv_from` enregistrées avant arrêt du serveur.
pub recv_errors: AtomicU64,
}
impl UdpMetrics {
/// Crée un jeu de métriques vide enroulé dans un [`Arc`].
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
/// Enregistre la réception d'un datagramme de `bytes` octets.
#[inline]
pub fn inc_received(&self, bytes: u64) {
self.packets_received.fetch_add(1, Ordering::Relaxed);
self.bytes_received.fetch_add(bytes, Ordering::Relaxed);
}
/// Enregistre l'émission d'un datagramme de `bytes` octets vers un client.
#[inline]
pub fn inc_sent(&self, bytes: u64) {
self.packets_sent.fetch_add(1, Ordering::Relaxed);
self.bytes_sent.fetch_add(bytes, Ordering::Relaxed);
}
/// Enregistre un paquet ignoré (canal sans abonnés).
#[inline]
pub fn inc_dropped(&self) {
self.packets_dropped.fetch_add(1, Ordering::Relaxed);
}
/// Enregistre un échec `send_to` non fatal.
#[inline]
pub fn inc_send_error(&self) {
self.send_errors.fetch_add(1, Ordering::Relaxed);
}
/// Enregistre un échec `recv_from`.
#[inline]
pub fn inc_recv_error(&self) {
self.recv_errors.fetch_add(1, Ordering::Relaxed);
}
/// Prend un instantané cohérent de tous les compteurs.
pub fn snapshot(&self) -> UdpMetricsSnapshot {
UdpMetricsSnapshot {
packets_received: self.packets_received.load(Ordering::Relaxed),
bytes_received: self.bytes_received.load(Ordering::Relaxed),
packets_sent: self.packets_sent.load(Ordering::Relaxed),
bytes_sent: self.bytes_sent.load(Ordering::Relaxed),
packets_dropped: self.packets_dropped.load(Ordering::Relaxed),
send_errors: self.send_errors.load(Ordering::Relaxed),
recv_errors: self.recv_errors.load(Ordering::Relaxed),
}
}
}
// ── Snapshot ─────────────────────────────────────────────────────────────────
/// Lecture cohérente de l'ensemble des compteurs à un instant T.
///
/// Permet de calculer des deltas et des taux entre deux points dans le temps
/// sans bloquer la boucle de routage.
#[derive(Debug, Clone, Copy, Default)]
pub struct UdpMetricsSnapshot {
pub packets_received: u64,
pub bytes_received: u64,
pub packets_sent: u64,
pub bytes_sent: u64,
pub packets_dropped: u64,
pub send_errors: u64,
pub recv_errors: u64,
}
impl UdpMetricsSnapshot {
/// Calcule les taux moyens par seconde depuis un snapshot précédent.
///
/// `elapsed` est la durée réelle écoulée entre les deux snapshots.
pub fn rates_since(&self, previous: &Self, elapsed: Duration) -> UdpRates {
// On évite la division par zéro si l'interval est infinitésimal.
let secs = elapsed.as_secs_f64().max(f64::EPSILON);
UdpRates {
packets_received_per_sec: self
.packets_received
.saturating_sub(previous.packets_received)
as f64
/ secs,
bytes_received_per_sec: self.bytes_received.saturating_sub(previous.bytes_received)
as f64
/ secs,
packets_sent_per_sec: self.packets_sent.saturating_sub(previous.packets_sent) as f64
/ secs,
bytes_sent_per_sec: self.bytes_sent.saturating_sub(previous.bytes_sent) as f64 / secs,
packets_dropped_per_sec: self
.packets_dropped
.saturating_sub(previous.packets_dropped)
as f64
/ secs,
}
}
}
// ── Taux ─────────────────────────────────────────────────────────────────────
/// Taux moyens par seconde calculés entre deux [`UdpMetricsSnapshot`].
#[derive(Debug, Clone, Copy)]
pub struct UdpRates {
/// Paquets reçus par seconde.
pub packets_received_per_sec: f64,
/// Octets reçus par seconde.
pub bytes_received_per_sec: f64,
/// Paquets envoyés par seconde.
pub packets_sent_per_sec: f64,
/// Octets envoyés par seconde.
pub bytes_sent_per_sec: f64,
/// Paquets ignorés par seconde.
pub packets_dropped_per_sec: f64,
}
// ── Reporter périodique ───────────────────────────────────────────────────────
/// Lance une tâche Tokio qui logue les métriques toutes les `interval`.
///
/// Chaque rapport inclut les compteurs cumulatifs **et** les taux moyens sur
/// la fenêtre écoulée depuis le rapport précédent.
///
/// # Exemple
/// ```no_run
/// use std::time::Duration;
/// use std::sync::Arc;
/// use oxspeak_server_lib::udp::metrics::{UdpMetrics, spawn_reporter};
///
/// #[tokio::main]
/// async fn main() {
/// let metrics = UdpMetrics::new();
/// spawn_reporter(Arc::clone(&metrics), Duration::from_secs(5));
/// }
/// ```
pub fn spawn_reporter(metrics: Arc<UdpMetrics>, interval: Duration) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
// Le premier tick est immédiat ; on le consomme pour démarrer à t=0.
ticker.tick().await;
let mut prev_snapshot = metrics.snapshot();
let mut prev_instant = Instant::now();
loop {
ticker.tick().await;
let now = Instant::now();
let current = metrics.snapshot();
let elapsed = now.duration_since(prev_instant);
let rates = current.rates_since(&prev_snapshot, elapsed);
tracing::info!(
// ── Cumulatifs ──
pkts_rx = current.packets_received,
bytes_rx = current.bytes_received,
pkts_tx = current.packets_sent,
bytes_tx = current.bytes_sent,
pkts_dropped = current.packets_dropped,
send_errors = current.send_errors,
recv_errors = current.recv_errors,
// ── Taux / s ──
pkts_rx_s = format!("{:.1}", rates.packets_received_per_sec),
bytes_rx_s = format!("{:.0}", rates.bytes_received_per_sec),
pkts_tx_s = format!("{:.1}", rates.packets_sent_per_sec),
bytes_tx_s = format!("{:.0}", rates.bytes_sent_per_sec),
pkts_dropped_s = format!("{:.1}", rates.packets_dropped_per_sec),
interval_ms = elapsed.as_millis(),
"UDP metrics"
);
prev_snapshot = current;
prev_instant = now;
}
});
}
+3
View File
@@ -0,0 +1,3 @@
pub mod metrics;
pub mod router;
pub mod server;
+61
View File
@@ -0,0 +1,61 @@
use std::collections::HashMap;
use std::net::SocketAddr;
/// Identifiant d'un canal de routage (ex: room ID, channel name…).
pub type ChannelId = String;
/// Table de routage UDP.
///
/// Associe un identifiant de canal à la liste des clients (adresses IP/port)
/// actuellement abonnés à ce canal. Le serveur UDP utilise cette table pour
/// décider où retransmettre les paquets entrants.
///
/// # Note future
/// Ce type est intentionnellement simple pour démarrer. La prochaine étape
/// sera d'y associer une logique de dispatch (ex: forwarding sélectif,
/// authentification du client, etc.).
#[derive(Debug, Default)]
pub struct RoutingTable {
channels: HashMap<ChannelId, Vec<SocketAddr>>,
}
impl RoutingTable {
/// Crée une table de routage vide.
pub fn new() -> Self {
Self::default()
}
/// Inscrit un client dans un canal.
///
/// Si le canal n'existe pas encore, il est créé automatiquement.
/// Si le client est déjà inscrit dans ce canal, l'appel est sans effet.
pub fn join(&mut self, channel: impl Into<ChannelId>, client: SocketAddr) {
let clients = self.channels.entry(channel.into()).or_default();
if !clients.contains(&client) {
clients.push(client);
}
}
/// Retire un client d'un canal.
///
/// Si le canal devient vide après le retrait, il est supprimé de la table.
pub fn leave(&mut self, channel: &str, client: &SocketAddr) {
if let Some(clients) = self.channels.get_mut(channel) {
clients.retain(|c| c != client);
if clients.is_empty() {
self.channels.remove(channel);
}
}
}
/// Retourne la liste des clients abonnés à un canal, ou `None` si le
/// canal n'existe pas.
pub fn subscribers(&self, channel: &str) -> Option<&[SocketAddr]> {
self.channels.get(channel).map(Vec::as_slice)
}
/// Retourne tous les canaux connus et leurs abonnés.
pub fn channels(&self) -> &HashMap<ChannelId, Vec<SocketAddr>> {
&self.channels
}
}
+182
View File
@@ -0,0 +1,182 @@
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::UdpSocket;
use tokio::sync::broadcast;
use super::metrics::UdpMetrics;
use super::router::RoutingTable;
use crate::config::NetworkConfig;
/// Taille du buffer de lecture pour chaque datagramme UDP entrant.
///
/// La RFC 768 limite les datagrammes UDP à 65 507 octets (payload max avec
/// en-têtes IP+UDP). Pour de la voix/vidéo compressée, les paquets réels
/// seront bien plus petits, mais on alloue le maximum une seule fois pour
/// éviter toute troncature silencieuse.
const UDP_READ_BUFFER_SIZE: usize = 65_507;
/// Erreurs pouvant survenir pendant l'opération du serveur UDP.
#[derive(Debug, thiserror::Error)]
pub enum UdpServerError {
#[error("failed to bind UDP socket to {addr}: {source}")]
Bind {
addr: SocketAddr,
#[source]
source: std::io::Error,
},
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
/// Serveur UDP asynchrone agissant comme routeur de paquets.
///
/// Reçoit des datagrammes entrants et les route vers les clients inscrits
/// dans les canaux correspondants via une [`RoutingTable`].
///
/// La configuration réseau est fournie par [`NetworkConfig`] (issue de
/// [`AppConfig`][crate::config::AppConfig]), qui centralise la lecture du
/// fichier TOML.
///
/// Les métriques sont collectées dans un [`UdpMetrics`] partageable via
/// [`Arc`] — passez-le à [`metrics::spawn_reporter`][super::metrics::spawn_reporter]
/// pour un reporting périodique automatique.
///
/// # Exemple
/// ```no_run
/// use std::time::Duration;
/// use oxspeak_server_lib::config::{AppConfig, NetworkConfig};
/// use oxspeak_server_lib::udp::server::UdpServer;
/// use oxspeak_server_lib::udp::metrics::{UdpMetrics, spawn_reporter};
///
/// #[tokio::main]
/// async fn main() {
/// let config = AppConfig::load().unwrap();
/// let metrics = UdpMetrics::new();
/// spawn_reporter(metrics.clone(), Duration::from_secs(10));
/// let (server, _shutdown_tx) = UdpServer::new(config.network, metrics);
/// server.run().await.unwrap();
/// }
/// ```
pub struct UdpServer {
bind_addr: SocketAddr,
routing_table: RoutingTable,
metrics: Arc<UdpMetrics>,
shutdown_rx: broadcast::Receiver<()>,
}
impl UdpServer {
/// Crée un nouveau [`UdpServer`] depuis la configuration réseau globale.
///
/// `metrics` est un [`Arc`] vers le jeu de métriques à alimenter.
/// Conservez-en un clone si vous souhaitez l'observer depuis l'extérieur
/// (reporter, API de santé, etc.).
///
/// Retourne le serveur et un [`broadcast::Sender`] pour déclencher le
/// shutdown gracieux.
pub fn new(network: NetworkConfig, metrics: Arc<UdpMetrics>) -> (Self, broadcast::Sender<()>) {
let bind_addr = SocketAddr::new(network.host.into(), network.udp_port);
let (shutdown_tx, shutdown_rx) = broadcast::channel(1);
(
Self {
bind_addr,
routing_table: RoutingTable::new(),
metrics,
shutdown_rx,
},
shutdown_tx,
)
}
/// Expose la table de routage de façon mutable pour y inscrire des
/// clients avant ou pendant l'exécution (via partage d'état ou messages).
pub fn routing_table_mut(&mut self) -> &mut RoutingTable {
&mut self.routing_table
}
/// Retourne une référence aux métriques du serveur.
pub fn metrics(&self) -> &Arc<UdpMetrics> {
&self.metrics
}
/// Bind le socket et démarre la boucle de routage.
///
/// Pour chaque datagramme reçu, le paquet est retransmis inline (sans
/// spawn de tâche) vers tous les clients abonnés au canal identifié.
/// La future se résout lorsqu'un signal de shutdown est reçu ou qu'une
/// erreur I/O fatale survient.
pub async fn run(mut self) -> Result<(), UdpServerError> {
let socket =
UdpSocket::bind(self.bind_addr)
.await
.map_err(|source| UdpServerError::Bind {
addr: self.bind_addr,
source,
})?;
tracing::info!(addr = %self.bind_addr, "UDP server listening");
let mut buf = vec![0u8; UDP_READ_BUFFER_SIZE];
loop {
tokio::select! {
result = socket.recv_from(&mut buf) => {
match result {
Ok((len, peer)) => {
self.metrics.inc_received(len as u64);
self.route_packet(&socket, &buf[..len], peer).await;
}
Err(err) => {
self.metrics.inc_recv_error();
tracing::error!(%err, "recv_from failed");
return Err(UdpServerError::Io(err));
}
}
}
_ = self.shutdown_rx.recv() => {
tracing::info!("UDP server shutting down");
break;
}
}
}
Ok(())
}
/// Route un paquet entrant vers les abonnés du canal correspondant.
///
/// # Logique actuelle (placeholder)
/// En l'absence de protocole applicatif défini, tous les paquets reçus
/// sont logués. Branche ici la logique d'identification du canal
/// (ex: lire un header de paquet pour extraire le `channel_id`).
async fn route_packet(&self, socket: &UdpSocket, data: &[u8], sender: SocketAddr) {
tracing::debug!(%sender, bytes = data.len(), "datagram received");
// TODO: extraire le channel_id depuis le header du paquet applicatif.
// Pour l'instant on utilise un canal de démonstration statique.
let channel_id = "default";
match self.routing_table.subscribers(channel_id) {
Some(clients) => {
for &client in clients {
// Ne pas renvoyer au sender lui-même.
if client == sender {
continue;
}
match socket.send_to(data, client).await {
Ok(_) => {
self.metrics.inc_sent(data.len() as u64);
}
Err(err) => {
self.metrics.inc_send_error();
tracing::warn!(%client, %err, "failed to forward packet");
}
}
}
}
None => {
self.metrics.inc_dropped();
tracing::debug!(%sender, channel = channel_id, "no subscribers, packet dropped");
}
}
}
}