//! Broadcast channel avec TTL et propagation de TopZero. //! Inspiré de `tokio::sync::broadcast` mais ajoute : //! - Capacité bornée avec blocage des producteurs quand aucun slot n’est libre. //! - Expiration automatique des messages (TTL) pour libérer les slots. //! - Propagation d’un compteur `epoch` incrémenté sur chaque TopZeroSync. use std::{ collections::VecDeque, fmt, sync::{ atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, Arc, Mutex, Weak, }, time::{Duration, Instant}, }; use tokio::sync::Notify; use tracing::{info, trace, warn}; /// Tolérance pour détecter un timestamp à zéro (TopZero). const TOP_ZERO_EPSILON: f64 = 1e-9; pub const DEFAULT_BROADCAST_MAX_LEAD_TIME: f64 = 0.5; /// Paquet diffusé contenant la charge utile + méta timing. #[derive(Clone)] pub struct TimedPacket { /// Charge utile diffusée aux clients. pub payload: T, /// Timestamp audio relatif (en secondes) pour pacing côté client. pub audio_timestamp: f64, /// Compteur incrémenté lorsqu'un TopZeroSync est reçu. pub epoch: u64, } impl fmt::Debug for TimedPacket { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("TimedPacket") .field("audio_timestamp", &self.audio_timestamp) .field("epoch", &self.epoch) .finish_non_exhaustive() } } /// Erreur remontée par `Receiver::try_recv`. #[derive(Debug)] pub enum TryRecvError { /// Aucun paquet n'est disponible pour le moment. Empty, /// Le receiver est en retard : le champ contient combien de paquets ont expiré /// ou ont déjà été consommés par les autres abonnés. /// /// Ce cas survient lorsque `purge_expired()` avance `head_seq` et que ce /// `Receiver` réclamait encore l'un des numéros supprimés. Le client doit /// donc ignorer les données perdues et se resynchroniser sur les paquets /// courants. Lagged(u64), /// Le channel est fermé et plus aucun paquet n'est disponible. Closed, } /// Erreur remontée par `Receiver::recv`. #[derive(Debug)] pub enum RecvError { Lagged(u64), Closed, } /// Erreur remontée par `Sender::send`. #[derive(Debug)] /// Erreur de diffusion détaillant la raison pour laquelle un paquet n'a pas été accepté. pub enum SendError { Closed(T), Expired(T), } struct Entry { seq: u64, expires_at: Instant, payload: T, audio_timestamp: f64, epoch: u64, } struct State { name: String, buffer: VecDeque>, head_seq: u64, next_seq: u64, closed: bool, epoch: u64, epoch_start: Instant, last_segment_end: Option, cursors: Vec>, initialized: bool, last_purge: Instant, } impl State { fn new(name: &str, capacity: usize, epoch_start: Instant) -> Self { Self { name: name.to_string(), buffer: VecDeque::with_capacity(capacity), head_seq: 0, next_seq: 0, closed: false, epoch: 0, epoch_start, last_segment_end: None, cursors: Vec::new(), initialized: false, last_purge: epoch_start, } } fn purge_expired(&mut self, now: Instant) -> bool { // Throttling : purger au maximum toutes les 20ms if now.duration_since(self.last_purge) < Duration::from_millis(20) { return false; } self.last_purge = now; let mut purged = 0u64; while let Some(entry) = self.buffer.front() { if entry.expires_at <= now { let delta = now - entry.expires_at; trace!( "TimedBroadcast[{}]: purging expired packet (@{} epoch={},delta={})", self.name, entry.seq, entry.epoch, delta.as_millis() ); self.buffer.pop_front(); self.head_seq += 1; purged += 1; } else { break; } } if purged > 0 { trace!( "TimedBroadcast[{}]: purged {} expired packet(s) (head_seq={})", self.name, purged, self.head_seq ); return true; } false } fn prune_consumed(&mut self) -> bool { let mut min_next = self.next_seq; let mut has_cursor = false; self.cursors.retain(|weak| { if let Some(cursor) = weak.upgrade() { let pos = cursor.next_seq.load(Ordering::SeqCst); if pos < min_next { min_next = pos; } has_cursor = true; true } else { false } }); if !has_cursor { return false; } let removable = min_next.saturating_sub(self.head_seq) as usize; if removable == 0 { return false; } for _ in 0..removable { let oentry = self.buffer.pop_front(); if oentry.is_some() { let entry = oentry.unwrap(); trace!( "TimedBroadcast[{}]: pruning played packet (@{} epoch={})", self.name, entry.seq, entry.epoch ); self.head_seq += 1; } } true } } struct Inner { state: Mutex>, data_notify: Notify, space_notify: Notify, capacity: usize, sender_count: AtomicUsize, receiver_count: AtomicUsize, is_closed: AtomicBool, } impl Inner { fn new(name: &str, capacity: usize) -> Self { Self { state: Mutex::new(State::new(name, capacity, Instant::now())), data_notify: Notify::new(), space_notify: Notify::new(), capacity, sender_count: AtomicUsize::new(1), receiver_count: AtomicUsize::new(0), is_closed: AtomicBool::new(false), } } fn close(&self) { if !self.is_closed.swap(true, Ordering::SeqCst) { if let Ok(mut state) = self.state.lock() { state.closed = true; } self.data_notify.notify_waiters(); self.space_notify.notify_waiters(); } } } /// Créé un channel broadcast temporisé. pub fn channel(name: &str, capacity: usize) -> (Sender, Receiver) { assert!(capacity > 0, "capacity must be > 0"); let inner = Arc::new(Inner::new(name, capacity)); let next_seq = { let state = inner.state.lock().expect("timed broadcast mutex poisoned"); state.next_seq }; let sender = Sender { inner: inner.clone(), }; let cursor = Arc::new(ReceiverCursor { next_seq: AtomicU64::new(next_seq), }); { let mut state = inner.state.lock().expect("timed broadcast mutex poisoned"); state.cursors.push(Arc::downgrade(&cursor)); } inner.receiver_count.store(1, Ordering::SeqCst); let receiver = Receiver { inner, next_seq, cursor, }; (sender, receiver) } /// Sender côté producteur. pub struct Sender { inner: Arc>, } impl Clone for Sender { fn clone(&self) -> Self { self.inner.sender_count.fetch_add(1, Ordering::SeqCst); Self { inner: self.inner.clone(), } } } impl Sender { /// Diffuse un paquet. Bloque si la capacité est atteinte avec des paquets non périmés. /// /// Le TTL de chaque paquet est calculé à partir du `epoch_start` courant et du /// `audio_timestamp` fournis, ce qui signifie qu’un receiver en retard finira /// par recevoir un [`TryRecvError::Lagged`] lorsque `expires_at` est dépassé. pub async fn send( &self, payload: T, audio_timestamp: f64, segment_duration: f64, ) -> Result> where T: Clone, { let mut payload = Some(payload); loop { let mut wait_deadline = None; { let mut state = self .inner .state .lock() .expect("timed broadcast mutex poisoned"); if state.closed { return Err(SendError::Closed( payload.expect("payload already consumed"), )); } // Capturer le temps UNE SEULE FOIS pour cohérence temporelle let now = Instant::now(); // 1. Purger d'abord les paquets expirés et consommés pour libérer l'espace // (skip pour le tout premier paquet) if state.buffer.len() > 0 { let consumed = state.prune_consumed(); let expired = state.purge_expired(now); if consumed || expired { self.inner.space_notify.notify_waiters(); } } // 2. Vérifier si un slot est disponible et insérer let is_top_zero = audio_timestamp.abs() < TOP_ZERO_EPSILON && segment_duration >= TOP_ZERO_EPSILON; let is_zero_header = audio_timestamp.abs() < TOP_ZERO_EPSILON && segment_duration < TOP_ZERO_EPSILON; if state.buffer.len() < self.inner.capacity { if !state.initialized { if !is_top_zero && segment_duration >= TOP_ZERO_EPSILON { warn!( "TimedBroadcast[{}]: First packet has non-zero timestamp {:.1}ms - Duration={:.1}ms, treating as epoch start anyway", state.name, audio_timestamp*1000.0, segment_duration*1000.0 ); } state.epoch_start = now; state.epoch = 0; state.initialized = true; info!( "TimedBroadcast[{}]: initialized (epoch=0, ts={:.1}ms - Duration={:.1}ms)", state.name, audio_timestamp*1000.0, segment_duration*1000.0 ); } else if is_top_zero || is_zero_header { // Restart epoch on TopZero relative to current wall-clock time to avoid // expired packets when there's a long gap between tracks. Also trigger // on zero-duration headers (OGG BOS/comment) so the epoch is reset // before testing expiration. state.epoch_start = state .last_segment_end .map(|end| end.max(now)) .unwrap_or(now); // state.epoch_start = now; state.epoch = state.epoch.wrapping_add(1); info!( "TimedBroadcast[{}]: new epoch={} (continuous={} - Duration={}ms)", state.name, state.epoch, state.last_segment_end.is_some(), segment_duration*1000.0 ); } let expires_at = state.epoch_start + Duration::from_secs_f64(audio_timestamp + segment_duration); let is_first_packet = state.next_seq == 0; if !is_first_packet && !is_top_zero && !is_zero_header && expires_at <= now { let grace_period = Duration::from_millis(50); if now > expires_at + grace_period { warn!( "TimedBroadcast[{}]: rejecting already expired packet (ts={:.3}s, epoch={}, delta={}ms)", state.name, audio_timestamp, state.epoch, now.duration_since(expires_at).as_millis() ); return Err(SendError::Expired( payload.expect("payload already consumed"), )); } } let entry = Entry { seq: state.next_seq, expires_at, payload: payload.take().expect("payload already consumed"), audio_timestamp, epoch: state.epoch, }; state.next_seq += 1; state.buffer.push_back(entry); // 5. Only advance segment end for real audio (skip 0-duration metadata) if segment_duration >= TOP_ZERO_EPSILON { let new_end = expires_at; state.last_segment_end = Some(match state.last_segment_end.take() { Some(prev) => prev.max(new_end), None => new_end, }); } let receivers = self.inner.receiver_count.load(Ordering::SeqCst); drop(state); self.inner.data_notify.notify_waiters(); return Ok(receivers); } wait_deadline = state.buffer.front().map(|entry| entry.expires_at); } if let Some(deadline) = wait_deadline { let deadline = tokio::time::Instant::from_std(deadline); tokio::select! { _ = self.inner.space_notify.notified() => {}, _ = tokio::time::sleep_until(deadline) => {}, } } else { self.inner.space_notify.notified().await; } } } /// Crée un nouveau receiver abonné au flux. pub fn subscribe(&self) -> Receiver { let mut state = self .inner .state .lock() .expect("timed broadcast mutex poisoned"); let next_seq = state.next_seq; let cursor = Arc::new(ReceiverCursor { next_seq: AtomicU64::new(next_seq), }); state.cursors.push(Arc::downgrade(&cursor)); state.prune_consumed(); drop(state); self.inner.receiver_count.fetch_add(1, Ordering::SeqCst); Receiver { inner: self.inner.clone(), next_seq, cursor, } } /// Nombre actuel de receivers abonnés. pub fn receiver_count(&self) -> usize { self.inner.receiver_count.load(Ordering::SeqCst) } /// Ferme explicitement le channel. pub fn close(&self) { self.inner.close(); } } impl Drop for Sender { fn drop(&mut self) { if self.inner.sender_count.fetch_sub(1, Ordering::SeqCst) == 1 { self.inner.close(); } } } /// Receiver côté consommateur. /// /// Chaque receiver garde son propre curseur `next_seq`. Si le producteur /// recycle un paquet via `purge_expired()` avant que ce curseur ne l’ait lu, /// la prochaine tentative de lecture retournera [`TryRecvError::Lagged`]. pub struct Receiver { inner: Arc>, next_seq: u64, cursor: Arc, } struct ReceiverCursor { next_seq: AtomicU64, } impl Receiver where T: Clone, { fn poll_entry(&mut self) -> Result, TryRecvError> { let mut state = self .inner .state .lock() .expect("timed broadcast mutex poisoned"); if state.closed && state.buffer.is_empty() { return Err(TryRecvError::Closed); } let now = Instant::now(); if state.purge_expired(now) { self.inner.space_notify.notify_waiters(); } if self.next_seq < state.head_seq { let skipped = state.head_seq - self.next_seq; self.next_seq = state.head_seq; return Err(TryRecvError::Lagged(skipped)); } let offset = (self.next_seq - state.head_seq) as usize; if offset < state.buffer.len() { let entry = state.buffer.get(offset).expect("invalid buffer offset"); let packet = TimedPacket { payload: entry.payload.clone(), audio_timestamp: entry.audio_timestamp, epoch: entry.epoch, }; self.next_seq += 1; self.cursor.next_seq.store(self.next_seq, Ordering::SeqCst); if state.prune_consumed() { self.inner.space_notify.notify_waiters(); } return Ok(packet); } if state.closed { Err(TryRecvError::Closed) } else { Err(TryRecvError::Empty) } } /// Version synchrone utilisée dans `poll_read`. /// /// # Erreurs /// /// * [`TryRecvError::Lagged`] — des paquets ont expiré avant d'être consommés. /// * [`TryRecvError::Empty`] — la file est vide pour l'instant. /// * [`TryRecvError::Closed`] — plus aucun paquet n'arrivera. pub fn try_recv(&mut self) -> Result, TryRecvError> { self.poll_entry() } /// Attends qu'un paquet soit disponible. pub async fn recv(&mut self) -> Result, RecvError> { loop { match self.try_recv() { Ok(packet) => return Ok(packet), Err(TryRecvError::Empty) => { self.inner.data_notify.notified().await; } Err(TryRecvError::Lagged(skipped)) => return Err(RecvError::Lagged(skipped)), Err(TryRecvError::Closed) => return Err(RecvError::Closed), } } } } impl Clone for Receiver { fn clone(&self) -> Self { self.inner.receiver_count.fetch_add(1, Ordering::SeqCst); let cursor = Arc::new(ReceiverCursor { next_seq: AtomicU64::new(self.next_seq), }); { let mut state = self .inner .state .lock() .expect("timed broadcast mutex poisoned"); state.cursors.push(Arc::downgrade(&cursor)); } Self { inner: self.inner.clone(), next_seq: self.next_seq, cursor, } } } impl Drop for Receiver { fn drop(&mut self) { self.cursor.next_seq.store(self.next_seq, Ordering::SeqCst); if let Ok(mut state) = self.inner.state.lock() { if state.prune_consumed() { self.inner.space_notify.notify_waiters(); } } if self.inner.receiver_count.fetch_sub(1, Ordering::SeqCst) == 1 { self.inner.space_notify.notify_waiters(); } } } /// Calculate broadcast channel capacity based on max_lead_time. /// /// Estimates the number of items needed to buffer max_lead_time seconds of audio. /// Assumes ~20 items per second (50ms per chunk). /// /// # Arguments /// /// * `max_lead_time` - Maximum lead time in seconds /// /// # Returns /// /// Broadcast channel capacity (minimum 100 items) pub(crate) fn calculate_broadcast_capacity(max_lead_time: f64) -> usize { // Estimation: ~20 items/second (chunks de 50ms en moyenne) // Pour 10s: 200 items let estimated_items_per_second = 20.0; let capacity = (max_lead_time * estimated_items_per_second) as usize; capacity.max(100) // Minimum 100 items }