chat: honest local send status + sender-side pacing (Phase 5)
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>
This commit is contained in:
2026-07-18 19:03:25 -04:00
co-authored by Claude Fable 5
parent 77d2bf2992
commit 5f4eba1815
6 changed files with 728 additions and 74 deletions
+31 -25
View File
@@ -271,8 +271,10 @@ const CHAT_REPLAY_CACHE_CAP: usize = 1024;
/// Per-author chat budget: a burst of 8 absorbs a fast typist; 1 msg/s sustained
/// is well above real human chat rate while bounding a flooder to a trickle.
const CHAT_AUTHOR_BURST: f64 = 8.0;
const CHAT_AUTHOR_REFILL_PER_MS: f64 = 1.0 / 1000.0;
/// `pub(crate)` because the sender-side pacer (chat-hardening Phase 5) mirrors
/// this exact policy — one definition, so the two sides can never drift apart.
pub(crate) const CHAT_AUTHOR_BURST: f64 = 8.0;
pub(crate) const CHAT_AUTHOR_REFILL_PER_MS: f64 = 1.0 / 1000.0;
/// Room-wide chat budget across ALL authors, so a set of sock-puppet identities
/// can't multiply the per-author budget into unbounded event-channel pressure.
@@ -290,15 +292,16 @@ const CHAT_AUTHOR_BUCKETS_CAP: usize = 64;
const CHAT_REJECT_LOG_COOLDOWN_MS: u64 = 10_000;
/// A minimal deterministic token bucket: time is passed in, never read from a
/// clock, so every boundary is unit-testable.
/// clock, so every boundary is unit-testable. Shared with the sender-side chat
/// pacer (`app::sendqueue`) so both sides of the rate policy use one mechanism.
#[derive(Debug, Clone, Copy)]
struct TokenBucket {
pub(crate) struct TokenBucket {
tokens: f64,
last_ms: u64,
}
impl TokenBucket {
fn full(burst: f64, now_ms: u64) -> Self {
pub(crate) fn full(burst: f64, now_ms: u64) -> Self {
Self {
tokens: burst,
last_ms: now_ms,
@@ -307,7 +310,7 @@ impl TokenBucket {
/// Refill for elapsed time (capped at `burst`), then take one token if
/// available. Returns whether a token was consumed.
fn try_take(&mut self, burst: f64, refill_per_ms: f64, now_ms: u64) -> bool {
pub(crate) fn try_take(&mut self, burst: f64, refill_per_ms: f64, now_ms: u64) -> bool {
let elapsed = now_ms.saturating_sub(self.last_ms) as f64;
self.tokens = (self.tokens + elapsed * refill_per_ms).min(burst);
self.last_ms = now_ms;
@@ -1276,26 +1279,29 @@ impl RoomState for IrohGossipState {
let sender_opt = self.active_sender.lock().unwrap().clone();
let topic_opt = *self.active_topic_bytes.lock().unwrap();
if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) {
let payload = sign_gossip(
&self.secret_key,
&topic,
// A missing sender/topic or an encode failure is a real send failure the
// caller must see (chat-hardening Phase 5) — silently returning Ok here
// would let the UI present an unsent message as broadcast.
let (Some(sender), Some(topic)) = (sender_opt, topic_opt) else {
return Err(NetError::Other("Not in a room".to_string()));
};
let payload = sign_gossip(
&self.secret_key,
&topic,
ts,
GossipMessage::Chat {
name,
text,
ts,
GossipMessage::Chat {
name,
text,
ts,
attachment,
},
);
if let Ok(bytes) = serde_json::to_vec(&payload) {
sender
.broadcast(bytes.into())
.await
.map_err(|e| NetError::Gossip(e.to_string()))?;
}
}
Ok(())
attachment,
},
);
let bytes = serde_json::to_vec(&payload)
.map_err(|e| NetError::Other(format!("Failed to encode chat: {e}")))?;
sender
.broadcast(bytes.into())
.await
.map_err(|e| NetError::Gossip(e.to_string()))
}
async fn leave(&self) -> Result<(), NetError> {