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:
+31
-25
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user