//! Sender-side chat send status and pacing (chat-hardening Phase 5). //! //! Every RECEIVER admits our chat through a per-author token bucket //! ([`CHAT_AUTHOR_BURST`] then 1/s) and silently drops what exceeds it, with no //! acknowledgement wire. The only way the sender can be honest about fast //! bursts is to never exceed that budget in the first place: sends past the //! burst are queued locally (shown as "queued…") and trickled out at the //! receivers' sustained rate. The pacer deliberately reuses the receiver //! gate's own [`TokenBucket`] and constants so the two sides of the policy //! cannot drift apart. //! //! Everything here is pure — `now_ms` is passed in, never read from a clock — //! so every boundary is unit-testable. use std::collections::VecDeque; use crate::network::gossip::{CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, TokenBucket}; /// Send lifecycle of one locally authored chat message. Success is /// [`SendStatus::Broadcast`] — "our signed frame was handed to the gossip /// swarm" — deliberately NOT "delivered": PeerSpeak has no peer /// acknowledgements, so the honest success presentation is no label at all. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SendStatus { /// Waiting in the local outbound queue for a pacer token. Queued, /// Handed to the core; the broadcast result has not come back yet. Pending, /// The signed broadcast reached the gossip swarm. Broadcast, /// The send failed; carries a short reason. The entry offers a Retry. Failed(String), } /// Local-only send bookkeeping attached to our own chat entries. The id never /// goes on the wire; it ties a `ChatSendResult` back to the matching echo. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LocalSend { pub id: u64, pub status: SendStatus, } /// Sender-side pacer mirroring the receiver's per-author admission budget. #[derive(Debug, Clone, Copy)] pub struct SendPacer { bucket: TokenBucket, } impl SendPacer { pub fn new(now_ms: u64) -> Self { Self { bucket: TokenBucket::full(CHAT_AUTHOR_BURST, now_ms), } } /// Take one send token if the mirrored per-author budget allows it now. pub fn try_send(&mut self, now_ms: u64) -> bool { self.bucket .try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, now_ms) } } /// Pop the queued ids that may be dispatched now: strict front-of-queue order, /// one pacer token each, stopping at the first refusal so a message can never /// overtake an earlier one. pub fn release_ready(queue: &mut VecDeque, pacer: &mut SendPacer, now_ms: u64) -> Vec { let mut ready = Vec::new(); while !queue.is_empty() && pacer.try_send(now_ms) { // The unwrap is safe: the loop condition just checked non-empty. ready.push(queue.pop_front().unwrap()); } ready } #[cfg(test)] mod tests { use super::*; const T0: u64 = 1_000_000; #[test] fn pacer_allows_the_full_burst_then_refuses() { let mut pacer = SendPacer::new(T0); for _ in 0..CHAT_AUTHOR_BURST as usize { assert!(pacer.try_send(T0)); } assert!(!pacer.try_send(T0)); } #[test] fn pacer_refills_at_one_per_second() { let mut pacer = SendPacer::new(T0); for _ in 0..CHAT_AUTHOR_BURST as usize { assert!(pacer.try_send(T0)); } // 999ms is just under one token; 1000ms grants exactly one. assert!(!pacer.try_send(T0 + 999)); assert!(pacer.try_send(T0 + 1000)); assert!(!pacer.try_send(T0 + 1000)); } #[test] fn release_ready_preserves_order_and_stops_at_refusal() { let mut pacer = SendPacer::new(T0); // Drain the burst so only refill tokens remain. for _ in 0..CHAT_AUTHOR_BURST as usize { assert!(pacer.try_send(T0)); } let mut queue: VecDeque = [10, 11, 12].into_iter().collect(); // 2 seconds of refill = 2 tokens: exactly the first two, in order. let ready = release_ready(&mut queue, &mut pacer, T0 + 2000); assert_eq!(ready, vec![10, 11]); assert_eq!(queue, VecDeque::from([12])); // No tokens left at the same instant. assert!(release_ready(&mut queue, &mut pacer, T0 + 2000).is_empty()); assert_eq!(queue, VecDeque::from([12])); } #[test] fn release_ready_empty_queue_consumes_no_tokens() { let mut pacer = SendPacer::new(T0); let mut queue = VecDeque::new(); assert!(release_ready(&mut queue, &mut pacer, T0).is_empty()); // The full burst must still be available. for _ in 0..CHAT_AUTHOR_BURST as usize { assert!(pacer.try_send(T0)); } } }