CI / check (push) Successful in 4m17s
Closes the final phase of docs/chat-hardening-plan.md. Two problems: a
locally echoed message always looked sent even when the core had no active
session or the gossip broadcast failed; and a fast burst could broadcast
'successfully' yet be silently dropped by every receiver's per-author rate
bucket (8 burst, then 1/s) with no sender feedback.
Send status: CoreCommand::SendChat/SendChatFile carry a local-only id (never
on the wire); the core replies with UiEvent::ChatSendResult after the gossip
broadcast succeeds or fails, and a no-active-session is now an explicit
failure rather than a silent no-op. gossip send_chat, which previously
returned Ok on a missing sender/topic or an encode failure, now returns Err.
ChatEntry gains local_send: Option<LocalSend>; failed sends render a red
'Not sent — {reason} [Retry]' line, Broadcast/Pending render nothing
(there are no delivery receipts, so silence is the honest success state).
Sender-side pacing (new src/app/sendqueue.rs): sends past the burst queue
locally as 'queued…' and trickle out at the receivers' sustained rate, so
nothing is lost and typing is never blocked (user chose queue-and-trickle
over input throttling). The pacer reuses the gossip gate's own TokenBucket +
per-author constants (now pub(crate)) so the two sides of the policy can't
drift. A 250ms drain subscription runs only while the queue is non-empty.
Retry re-dispatches the retained payload; re-serving the same attachment id
replaces the ServeStore entry rather than double-counting bytes. The pacer
and monotonic send-id counter survive a room reset (receivers' buckets
persist; ids never alias a late result); queue and retry payloads are cleared.
582 lib tests (+11: 4 pacer/queue seam, 7 app-level transition/retry/reset);
all-targets green, clippy -D warnings clean, fmt clean, smoke launch OK. No
wire change (GOSSIP_PROTO stays 5). Tests-green-only — the two owed
two-machine field-test items are logged in the plan.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
130 lines
4.7 KiB
Rust
130 lines
4.7 KiB
Rust
//! 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<u64>, pacer: &mut SendPacer, now_ms: u64) -> Vec<u64> {
|
|
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<u64> = [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));
|
|
}
|
|
}
|
|
}
|