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
+86 -26
View File
@@ -1,18 +1,23 @@
# Chat hardening — ephemeral implementation plan # Chat hardening — ephemeral implementation plan
**Status (2026-07-18):** Phases 14 COMPLETE. Phase 1 = shared text policy in **Status (2026-07-18):** Phases 15 COMPLETE (all plan phases done). Phase 1 =
`src/sanitize.rs`, ceilings enforced at UI input, sign point, and gossip ingress. shared text policy in `src/sanitize.rs`, ceilings enforced at UI input, sign
Phase 2 = roster-bound authorship (`src/core/chatroster.rs`), replay dedup + rate point, and gossip ingress. Phase 2 = roster-bound authorship
limits (`ChatIngressGate` in `src/network/gossip.rs`). Phase 3 = attachment (`src/core/chatroster.rs`), replay dedup + rate limits (`ChatIngressGate` in
cache/serve-store budgets, downscaled previews, auto-fetch byte/request budgets `src/network/gossip.rs`). Phase 3 = attachment cache/serve-store budgets,
(`src/core/fetchbudget.rs`), exact transfers, bounded local reads. Phase 4 = downscaled previews, auto-fetch byte/request budgets (`src/core/fetchbudget.rs`),
parsed-URL link policy (`is_safe_web_url`/`link_ranges` in `src/sanitize.rs`, exact transfers, bounded local reads. Phase 4 = parsed-URL link policy
`url` crate), 8-link cap, cached link ranges in `ChatEntry`, 512 KiB history (`is_safe_web_url`/`link_ranges` in `src/sanitize.rs`, `url` crate), 8-link cap,
text budget, chat-body bidi-override strip (closes S14). All gates green each cached link ranges in `ChatEntry`, 512 KiB history text budget, chat-body
phase. Phase 5 not started. This is a temporary scope contract for hardening bidi-override strip (closes S14). Phase 5 = honest local send status
the existing room chat. Update the checkboxes and decision log as work lands, (`CoreCommand::SendChat`/`SendChatFile` carry a local id, `UiEvent::ChatSendResult`,
then delete this file when the work is complete. Do not add link previews as `SendStatus` on own echoes) PLUS sender-side pacing (`src/app/sendqueue.rs`
part of this effort. mirrors the receivers' per-author budget so fast bursts trickle instead of being
silently dropped downstream). All gates green each phase. This is a temporary
scope contract for hardening the existing room chat; with every phase complete
and the two-machine field test done, delete this file (see the completion note
at the end). The two-machine field-test section below is still owed before that
deletion. Do not add link previews as part of this effort.
## Goal ## Goal
@@ -294,24 +299,37 @@ small spans an unnecessary UI/launcher surface.
**Target:** never present a locally echoed message as successfully broadcast when **Target:** never present a locally echoed message as successfully broadcast when
the core rejected it or gossip broadcast failed. the core rejected it or gossip broadcast failed.
- [ ] Add a local-only message id and `Pending`/`Broadcast`/`Failed` state to local - [x] Add a local-only message id and `Pending`/`Broadcast`/`Failed` state to local
chat entries. Do not put this id or state on the wire. chat entries. Do not put this id or state on the wire. (`ChatEntry.local_send:
- [ ] Carry the local id through `CoreCommand::SendChat`/`SendChatFile` and return a Option<LocalSend>`; `SendStatus` also has `Queued` for the paced-but-not-yet-sent
state — see the pacing decision-log entry.)
- [x] Carry the local id through `CoreCommand::SendChat`/`SendChatFile` and return a
`UiEvent` result after the local gossip broadcast call succeeds or fails. `UiEvent` result after the local gossip broadcast call succeeds or fails.
- [ ] If the core is not in an active session, return failure instead of silently (`SendChat`/`SendChatFile` gained `local_id`; new `UiEvent::ChatSendResult { local_id,
doing nothing. error }`.)
- [ ] Show failure compactly with a retry action. A successful local broadcast must - [x] If the core is not in an active session, return failure instead of silently
doing nothing. (`send_chat` now `Err`s on missing sender/topic and on encode
failure; the core arm maps no-session to a `ChatSendResult` error.)
- [x] Show failure compactly with a retry action. A successful local broadcast must
not be labeled “delivered” or “read”; PeerSpeak has no peer acknowledgements. not be labeled “delivered” or “read”; PeerSpeak has no peer acknowledgements.
- [ ] Retry creates one new signed broadcast while retaining replay correctness and (Failed → red "⚠ Not sent — {reason} [Retry]" line; Broadcast/Pending render
attachment serving state. nothing — silence is the honest success state.)
- [x] Retry creates one new signed broadcast while retaining replay correctness and
attachment serving state. (`RetryChatSend(id)` re-dispatches the retained
`PendingSend`; re-serving the same attachment id REPLACES the `ServeStore`
entry, never double-counts — see `serve_store_replacement_accounting_and_remove_clear`.)
### Phase 5 tests ### Phase 5 tests
- [ ] Local echo starts pending, becomes broadcast on success, and becomes failed - [x] Local echo starts pending, becomes broadcast on success, and becomes failed
on no-session/channel/gossip error. on no-session/channel/gossip error. (`send_status_pending_then_broadcast_on_success`,
- [ ] Results update only the matching local entry, including after history `send_status_failed_keeps_payload_for_retry`.)
eviction or room reset. - [x] Results update only the matching local entry, including after history
- [ ] Retry does not duplicate served bytes or mutate an unrelated entry. eviction or room reset. (`send_result_updates_only_the_matching_entry`,
`send_result_after_eviction_drops_orphan_payload`, `send_result_after_room_reset_is_a_noop`.)
- [x] Retry does not duplicate served bytes or mutate an unrelated entry.
(`retry_redispatches_only_the_targeted_send`; served-byte dedup =
`serve_store_replacement_accounting_and_remove_clear` in `files.rs`.)
## Compatibility and versioning ## Compatibility and versioning
@@ -359,6 +377,11 @@ it; do not make ordinary unit tests depend on external network access.
rest as selectable plain text, with nothing dropped. rest as selectable plain text, with nothing dropped.
- [ ] A message attempting bidi-override display spoofing renders in send order - [ ] A message attempting bidi-override display spoofing renders in send order
(the override characters are stripped, emoji/joining-script text intact). (the override characters are stripped, emoji/joining-script text intact).
- [ ] Send a fast burst (>8 messages in a second): all arrive at the peer in
order, none silently lost; the sender sees "queued…" on the overflow that
then clears as each goes out.
- [ ] Send with no active session (or a failing broadcast): the message shows
"⚠ Not sent" with a Retry, and Retry resends it once when connectivity is back.
## Completion criteria ## Completion criteria
@@ -522,3 +545,40 @@ The plan is complete when:
an open Save/Play on an evicted line keeps its bytes-in-hand (the save an open Save/Play on an evicted line keeps its bytes-in-hand (the save
dialog falls back to the generic "download" name). The just-pushed entry is dialog falls back to the generic "download" name). The just-pushed entry is
never evicted; a single message's 8 KiB ceiling cannot exceed the budget. never evicted; a single message's 8 KiB ceiling cannot exceed the budget.
- **2026-07-18 (Phase 5):** Sender-side PACING was added to Phase 5's scope
(originally receiver-status only). The Phase 2 decision log deferred the
"apply the same local submit policy to accidental rapid Enter" item to pair
with Phase 5, and honest status alone would still let a fast burst broadcast
successfully yet be silently dropped by every receiver's per-author bucket
(8 burst, then 1/s) with no sender feedback. The user chose "queue and
trickle" over "throttle input": sends past the burst queue locally as
`SendStatus::Queued` ("queued…") and release at the receivers' sustained
rate, so nothing is lost and typing is never blocked.
- **2026-07-18 (Phase 5):** The pacer (`src/app/sendqueue.rs`) reuses the
gossip gate's OWN `TokenBucket` + `CHAT_AUTHOR_BURST`/`CHAT_AUTHOR_REFILL_PER_MS`
(made `pub(crate)`), so the two sides of the rate policy are one definition
and cannot drift. It mirrors only the PER-AUTHOR budget, not the room-wide
one — we cannot know other members' send rates, and the per-author bucket is
the one guaranteed to apply to us at every receiver.
- **2026-07-18 (Phase 5):** Send status renders as a line UNDER the message
(user pick over an inline suffix glyph); `Broadcast` and the transient
`Pending` show nothing because PeerSpeak has no delivery/read receipts, so an
unadorned message IS the honest "handed to the swarm" state. Only `Queued`
and `Failed` (with Retry) are surfaced.
- **2026-07-18 (Phase 5):** The pacer and the monotonic send-id counter
deliberately SURVIVE a room reset while the queue and retry payloads are
cleared: receivers' per-author buckets persist across our rejoin (so the
pacer should not refill to full), and never-reused ids keep a late
`ChatSendResult` from a pre-reset send from aliasing a new entry — verified by
`send_result_after_room_reset_is_a_noop`.
- **2026-07-18 (Phase 5):** The pacer clock is `Instant`-based
(`AppState.send_clock`), not wall-clock, so a system time jump can neither
rewind nor fast-forward the send budget.
## Completion
All five phases are implemented and every gate is green. Per the scope-contract
note at the top, this file should be DELETED once the owed two-machine field
test (the checklist below) has been run — that deletion is a separate,
user-gated step, not part of the Phase 5 commit. Until then the plan stays as
the record of what shipped and what remains to verify on real hardware.
+410 -7
View File
@@ -20,6 +20,9 @@ use crate::theme::{AppTheme, Palette};
use crate::widget::context_input::{context_input, locked_value}; use crate::widget::context_input::{context_input, locked_value};
use crate::widget::selectable_text::selectable_rich_text; use crate::widget::selectable_text::selectable_rich_text;
mod sendqueue;
use sendqueue::{LocalSend, SendPacer, SendStatus};
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program}; use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
use iced::widget::text_input; use iced::widget::text_input;
use iced::widget::{ use iced::widget::{
@@ -324,6 +327,22 @@ struct ChatEntry {
/// cached ranges instead of rescanning and re-validating every message. /// cached ranges instead of rescanning and re-validating every message.
/// At most [`crate::sanitize::CHAT_MSG_MAX_LINKS`] entries. /// At most [`crate::sanitize::CHAT_MSG_MAX_LINKS`] entries.
links: Vec<std::ops::Range<usize>>, links: Vec<std::ops::Range<usize>>,
/// Local-only send bookkeeping (chat-hardening Phase 5): `Some` exactly on
/// our own locally echoed messages, `None` on peers'. Never on the wire.
local_send: Option<LocalSend>,
}
/// Everything needed to (re)dispatch one local send (chat-hardening Phase 5).
/// Held in `AppState.send_payloads` from submit until the send is Broadcast, so
/// a queued release or a Retry rebuilds the exact same `CoreCommand`.
#[derive(Debug, Clone)]
enum PendingSend {
Text(String),
File {
text: String,
attachment: crate::files::ChatAttachment,
data: Arc<Vec<u8>>,
},
} }
/// Fetch state of a chat attachment's bytes (session-only). Absence from the /// Fetch state of a chat attachment's bytes (session-only). Absence from the
@@ -824,6 +843,11 @@ pub enum AppMessage {
AudioTick, AudioTick,
/// Send the current chat input line (Enter or the Send button). /// Send the current chat input line (Enter or the Send button).
ChatSubmit, ChatSubmit,
/// Drain cadence for the outbound chat queue while it is non-empty
/// (Phase 5 pacing): dispatches queued sends as pacer tokens refill.
ChatSendTick,
/// Re-dispatch a Failed local send (the "Retry" button), by local send id.
RetryChatSend(u64),
/// Open a clicked chat link in the system browser (A13). /// Open a clicked chat link in the system browser (A13).
OpenUrl(String), OpenUrl(String),
/// A room divider was dragged by the given pixel delta along its drag axis /// A room divider was dragged by the given pixel delta along its drag axis
@@ -1045,6 +1069,22 @@ pub struct AppState {
pending_saves: HashSet<AttachmentKey>, pending_saves: HashSet<AttachmentKey>,
/// Clip ids waiting for the existing attachment fetch path to return bytes. /// Clip ids waiting for the existing attachment fetch path to return bytes.
pending_plays: HashSet<crate::files::AttachmentId>, pending_plays: HashSet<crate::files::AttachmentId>,
/// Monotonic id source for local chat sends (Phase 5). Never reset within a
/// run, so a stale result after a room reset can't alias a new entry.
next_send_id: u64,
/// Monotonic clock base for the send pacer (wall clocks can jump).
send_clock: std::time::Instant,
/// Sender-side pacer mirroring the receivers' per-author chat budget.
send_pacer: SendPacer,
/// Local send ids waiting for a pacer token, oldest first ("queued…").
send_queue: VecDeque<u64>,
/// Dispatch payload for each in-flight local send, keyed by local id. Holds
/// everything needed to (re)dispatch: it backs both queued release and the
/// Retry button. For file sends the `Arc` is one clone of the same
/// allocation the cache/serve store share. Retained from submit until the
/// send is Broadcast (or the room resets); a Failed send keeps its payload
/// so Retry can re-dispatch without re-reading the file.
send_payloads: HashMap<u64, PendingSend>,
/// Filename-hinted audio whose fetched bytes or decoder validation failed; /// Filename-hinted audio whose fetched bytes or decoder validation failed;
/// these entries fall back to the normal file chip. /// these entries fall back to the normal file chip.
invalid_audio: HashSet<crate::files::AttachmentId>, invalid_audio: HashSet<crate::files::AttachmentId>,
@@ -1201,6 +1241,12 @@ impl AppState {
self.conn_stats.clear(); self.conn_stats.clear();
self.locally_muted.clear(); self.locally_muted.clear();
self.chat_messages.clear(); self.chat_messages.clear();
// Unsent queue + retry bytes die with the room's transcript. The pacer
// and id counter deliberately survive: receivers' per-author buckets
// persist across our rejoin, and monotonic ids keep any in-flight
// result from aliasing a post-reset entry.
self.send_queue.clear();
self.send_payloads.clear();
self.chat_input.clear(); self.chat_input.clear();
self.attachments.clear(); self.attachments.clear();
self.image_lightbox = None; self.image_lightbox = None;
@@ -1225,6 +1271,98 @@ impl AppState {
self.clock_skew_warning = None; self.clock_skew_warning = None;
} }
/// Monotonic milliseconds for the send pacer. `Instant`-based so a wall-clock
/// jump can never rewind or fast-forward the pacing budget.
fn send_now_ms(&self) -> u64 {
self.send_clock.elapsed().as_millis() as u64
}
/// Point the most recent local echo with this id at a new send status. Returns
/// whether such an entry still exists (it may have been evicted from history or
/// dropped by a room reset) so callers can release orphaned payloads.
fn set_send_status(&mut self, id: u64, status: SendStatus) -> bool {
if let Some(entry) = self
.chat_messages
.iter_mut()
.rev()
.find(|m| m.local_send.as_ref().is_some_and(|s| s.id == id))
{
if let Some(ls) = entry.local_send.as_mut() {
ls.status = status;
}
true
} else {
false
}
}
/// Hand one payload to the core, marking the entry Pending on success or
/// Failed if the core loop is gone (a dead reliable channel). The payload is
/// retained either way — a Retry can still re-dispatch after a dead-core
/// failure once the loop is back.
fn dispatch_send(&mut self, id: u64, payload: &PendingSend) {
let ok = match payload {
PendingSend::Text(text) => self.controller.send(CoreCommand::SendChat {
local_id: id,
text: text.clone(),
}),
PendingSend::File {
text,
attachment,
data,
} => self.controller.send(CoreCommand::SendChatFile {
local_id: id,
text: text.clone(),
attachment: attachment.clone(),
data: data.clone(),
}),
};
self.set_send_status(
id,
if ok {
SendStatus::Pending
} else {
SendStatus::Failed("core unavailable".to_string())
},
);
}
/// Land a core `ChatSendResult` on the matching local echo (Phase 5).
/// Success (`error = None`) means our signed frame reached the swarm — NOT a
/// delivery receipt — and the payload is done. A failure keeps the payload
/// so Retry can re-dispatch, unless the entry is already gone (history
/// eviction / room reset), in which case the payload is dropped so its map
/// can't leak. Either way an id with no matching entry is a harmless no-op.
fn apply_send_result(&mut self, local_id: u64, error: Option<String>) {
match error {
None => {
self.set_send_status(local_id, SendStatus::Broadcast);
self.send_payloads.remove(&local_id);
}
Some(e) => {
if !self.set_send_status(local_id, SendStatus::Failed(e)) {
self.send_payloads.remove(&local_id);
}
}
}
}
/// Decide a fresh (or retried) send's fate: dispatch immediately only when
/// nothing is already queued AND the pacer — mirroring the receivers'
/// per-author budget — grants a token now; otherwise queue it as "queued…"
/// so it can never overtake an earlier message or exceed what receivers
/// admit. The payload is stored for release/retry.
fn submit_send(&mut self, id: u64, payload: PendingSend) {
self.send_payloads.insert(id, payload.clone());
let now = self.send_now_ms();
if self.send_queue.is_empty() && self.send_pacer.try_send(now) {
self.dispatch_send(id, &payload);
} else {
self.set_send_status(id, SendStatus::Queued);
self.send_queue.push_back(id);
}
}
fn custom_sound_path(&self, sound: Sound) -> &str { fn custom_sound_path(&self, sound: Sound) -> &str {
let opt = match sound { let opt = match sound {
Sound::SelfJoin => &self.config.custom_sound_self_join, Sound::SelfJoin => &self.config.custom_sound_self_join,
@@ -1376,6 +1514,11 @@ impl Default for AppState {
image_lightbox: None, image_lightbox: None,
pending_saves: HashSet::new(), pending_saves: HashSet::new(),
pending_plays: HashSet::new(), pending_plays: HashSet::new(),
next_send_id: 0,
send_clock: std::time::Instant::now(),
send_pacer: SendPacer::new(0),
send_queue: VecDeque::new(),
send_payloads: HashMap::new(),
invalid_audio: HashSet::new(), invalid_audio: HashSet::new(),
clip_player, clip_player,
clip_status, clip_status,
@@ -1656,12 +1799,22 @@ fn subscription(state: &AppState) -> Subscription<AppMessage> {
} else { } else {
Subscription::none() Subscription::none()
}; };
// Drain the outbound chat queue while it has waiting messages (Phase 5
// pacing). A queued message is released only as the pacer refills (~1/s), so
// a 250ms poll is responsive without busy-looping; it stops entirely once
// the queue empties.
let send_queue_sub = if state.send_queue.is_empty() {
Subscription::none()
} else {
iced::time::every(std::time::Duration::from_millis(250)).map(|_| AppMessage::ChatSendTick)
};
Subscription::batch(vec![ Subscription::batch(vec![
core_sub, core_sub,
event_sub, event_sub,
audio_sub, audio_sub,
clock_skew_sub, clock_skew_sub,
rescan_label_sub, rescan_label_sub,
send_queue_sub,
]) ])
} }
@@ -2141,6 +2294,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.recording_started = None; state.recording_started = None;
state.status_message = format!("Saved recording → {path}"); state.status_message = format!("Saved recording → {path}");
} }
UiEvent::ChatSendResult { local_id, error } => {
// Land the honest outcome on the matching local echo (Phase
// 5). `error = None` = our signed frame reached the swarm,
// NOT a delivery receipt. On success the payload is done; on
// failure it's retained for Retry — unless the entry is gone
// (history eviction / room reset), in which case drop it so
// the payload map can't leak.
state.apply_send_result(local_id, error);
}
UiEvent::ChatMessage { UiEvent::ChatMessage {
from, from,
name, name,
@@ -2169,6 +2331,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
from: Some(from), from: Some(from),
attachment, attachment,
links: Vec::new(), links: Vec::new(),
local_send: None,
}, },
); );
} }
@@ -3085,7 +3248,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::ChatSubmit => { AppMessage::ChatSubmit => {
let text = crate::sanitize::sanitize_chat(&state.chat_input); let text = crate::sanitize::sanitize_chat(&state.chat_input);
if !text.is_empty() { if !text.is_empty() {
// Local echo (gossip suppresses our own author, so it won't come back). // Local echo (gossip suppresses our own author, so it won't come
// back). The send is dispatched-or-queued through the pacer so a
// fast burst is trickled at the rate receivers actually admit,
// and the entry carries honest send status (Phase 5).
let id = state.next_send_id;
state.next_send_id += 1;
push_chat( push_chat(
&mut state.chat_messages, &mut state.chat_messages,
ChatEntry { ChatEntry {
@@ -3095,12 +3263,34 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
from: Some(state.self_id.clone()), from: Some(state.self_id.clone()),
attachment: None, attachment: None,
links: Vec::new(), links: Vec::new(),
local_send: Some(LocalSend {
id,
status: SendStatus::Pending,
}),
}, },
); );
let _ = state.controller.send(CoreCommand::SendChat(text)); state.submit_send(id, PendingSend::Text(text));
state.chat_input.clear(); state.chat_input.clear();
} }
} }
AppMessage::ChatSendTick => {
let now = state.send_now_ms();
let ready = sendqueue::release_ready(&mut state.send_queue, &mut state.send_pacer, now);
for id in ready {
if let Some(payload) = state.send_payloads.get(&id).cloned() {
state.dispatch_send(id, &payload);
}
}
}
AppMessage::RetryChatSend(id) => {
// Re-dispatch a Failed send from its retained payload. Guard against a
// double click while it is already queued/in-flight.
if !state.send_queue.contains(&id)
&& let Some(payload) = state.send_payloads.get(&id).cloned()
{
state.submit_send(id, payload);
}
}
AppMessage::PickAttachmentFile => { AppMessage::PickAttachmentFile => {
// Native picker off the UI thread; returns (filename, bytes). The // Native picker off the UI thread; returns (filename, bytes). The
// read is BOUNDED (metadata precheck + cap+1 read, Phase 3C) — an // read is BOUNDED (metadata precheck + cap+1 read, Phase 3C) — an
@@ -3171,6 +3361,8 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
.attachments .attachments
.insert(key, AttachmentState::Ready(bytes.clone()), handle); .insert(key, AttachmentState::Ready(bytes.clone()), handle);
} }
let send_id = state.next_send_id;
state.next_send_id += 1;
push_chat( push_chat(
&mut state.chat_messages, &mut state.chat_messages,
ChatEntry { ChatEntry {
@@ -3180,13 +3372,20 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
from: Some(state.self_id.clone()), from: Some(state.self_id.clone()),
attachment: Some(att.clone()), attachment: Some(att.clone()),
links: Vec::new(), links: Vec::new(),
local_send: Some(LocalSend {
id: send_id,
status: SendStatus::Pending,
}),
},
);
state.submit_send(
send_id,
PendingSend::File {
text: String::new(),
attachment: att,
data: bytes,
}, },
); );
let _ = state.controller.send(CoreCommand::SendChatFile {
text: String::new(),
attachment: att,
data: bytes,
});
} }
} }
AppMessage::SaveAttachment(key) => { AppMessage::SaveAttachment(key) => {
@@ -7514,6 +7713,42 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.spacing(0), .spacing(0),
); );
} }
// Honest send status under our own messages (Phase 5). Success
// (Broadcast) and the transient Pending deliberately show
// nothing — there are no delivery receipts, so silence is the
// honest "it went out" state. Only a waiting queue slot or an
// outright failure is surfaced.
if let Some(ls) = &m.local_send {
let status_elem: Option<Element<'_, AppMessage>> = match &ls.status {
SendStatus::Queued => {
Some(text("queued…").size(11).color(color_subtext).into())
}
SendStatus::Failed(reason) => Some(
row![
text(format!("⚠ Not sent — {reason}"))
.size(11)
.color(color_red),
button(text("Retry").size(11))
.on_press(AppMessage::RetryChatSend(ls.id))
.style(b_style(color_surface, color_red, color_text, 6.0))
.padding(4),
]
.spacing(6)
.align_y(iced::alignment::Vertical::Center)
.into(),
),
SendStatus::Pending | SendStatus::Broadcast => None,
};
if let Some(status_elem) = status_elem {
chat_col = chat_col.push(
row![
iced::widget::Space::new().width(iced::Length::Fixed(30.0)),
status_elem,
]
.spacing(0),
);
}
}
} }
} }
let chat_scroll = scrollable(chat_col) let chat_scroll = scrollable(chat_col)
@@ -9274,6 +9509,8 @@ impl Program<AppMessage> for Icon {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::PendingSend;
use super::sendqueue::{self, LocalSend, SendStatus};
use super::{ use super::{
AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState, AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState,
CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX, CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX,
@@ -9284,6 +9521,7 @@ mod tests {
update, update,
}; };
use iroh::SecretKey; use iroh::SecretKey;
use std::collections::VecDeque;
/// Two distinct (peer, id) keys for cache tests; `id` may be shared to model /// Two distinct (peer, id) keys for cache tests; `id` may be shared to model
/// a malicious peer reusing a victim's attachment id (Tier C F-12). /// a malicious peer reusing a victim's attachment id (Tier C F-12).
@@ -9497,6 +9735,7 @@ mod tests {
id: shared_id, id: shared_id,
}), }),
links: Vec::new(), links: Vec::new(),
local_send: None,
}; };
// Attacker's line is FIRST in history, so a bare-id scan would pick it. // Attacker's line is FIRST in history, so a bare-id scan would pick it.
let messages = vec![mk(attacker, "evil.sh"), mk(victim, "report.pdf")]; let messages = vec![mk(attacker, "evil.sh"), mk(victim, "report.pdf")];
@@ -9544,6 +9783,7 @@ mod tests {
from: Some(peer.to_string()), from: Some(peer.to_string()),
attachment: None, attachment: None,
links: Vec::new(), links: Vec::new(),
local_send: None,
}); });
state.chat_input = "draft".to_string(); state.chat_input = "draft".to_string();
let att_key = (peer, attachment_id); let att_key = (peer, attachment_id);
@@ -10453,6 +10693,7 @@ mod tests {
from: None, from: None,
attachment: None, attachment: None,
links: Vec::new(), links: Vec::new(),
local_send: None,
}; };
push_chat(&mut messages, entry); push_chat(&mut messages, entry);
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
@@ -10475,6 +10716,7 @@ mod tests {
from: None, from: None,
attachment: None, attachment: None,
links: Vec::new(), links: Vec::new(),
local_send: None,
}, },
); );
} }
@@ -10506,6 +10748,7 @@ mod tests {
from: None, from: None,
attachment: None, attachment: None,
links: Vec::new(), links: Vec::new(),
local_send: None,
}, },
); );
} }
@@ -10541,6 +10784,7 @@ mod tests {
from: None, from: None,
attachment: None, attachment: None,
links: Vec::new(), links: Vec::new(),
local_send: None,
}, },
); );
let total: usize = messages.iter().map(|m| m.text.len()).sum(); let total: usize = messages.iter().map(|m| m.text.len()).sum();
@@ -10568,6 +10812,7 @@ mod tests {
from: None, from: None,
attachment: None, attachment: None,
links: Vec::new(), links: Vec::new(),
local_send: None,
}, },
); );
// Ranges are filled at push time (the construction-site value is // Ranges are filled at push time (the construction-site value is
@@ -10579,4 +10824,162 @@ mod tests {
let rebuilt: String = pieces.iter().map(|(s, _)| *s).collect(); let rebuilt: String = pieces.iter().map(|(s, _)| *s).collect();
assert_eq!(rebuilt, m.text); assert_eq!(rebuilt, m.text);
} }
// ---- Phase 5: honest send status (chat-hardening plan) ----
/// Push a locally authored echo the way `ChatSubmit` does and return its id.
fn push_own(state: &mut AppState, text: &str) -> u64 {
let id = state.next_send_id;
state.next_send_id += 1;
state.chat_messages.push(ChatEntry {
name: "Me (You)".to_string(),
text: text.to_string(),
mine: true,
from: Some(state.self_id.clone()),
attachment: None,
links: Vec::new(),
local_send: Some(LocalSend {
id,
status: SendStatus::Pending,
}),
});
id
}
fn status_of(state: &AppState, id: u64) -> Option<SendStatus> {
state
.chat_messages
.iter()
.find_map(|m| m.local_send.as_ref().filter(|s| s.id == id))
.map(|s| s.status.clone())
}
#[test]
fn send_status_pending_then_broadcast_on_success() {
let mut state = AppState::default();
let id = push_own(&mut state, "hi");
state.submit_send(id, PendingSend::Text("hi".to_string()));
// Empty queue + a fresh full pacer → dispatched immediately.
assert_eq!(status_of(&state, id), Some(SendStatus::Pending));
assert!(state.send_payloads.contains_key(&id));
state.apply_send_result(id, None);
assert_eq!(status_of(&state, id), Some(SendStatus::Broadcast));
// A completed send releases its retry payload.
assert!(!state.send_payloads.contains_key(&id));
}
#[test]
fn send_status_failed_keeps_payload_for_retry() {
let mut state = AppState::default();
let id = push_own(&mut state, "yo");
state.submit_send(id, PendingSend::Text("yo".to_string()));
state.apply_send_result(id, Some("not in a room".to_string()));
assert_eq!(
status_of(&state, id),
Some(SendStatus::Failed("not in a room".to_string()))
);
// Retained so Retry can re-dispatch the exact payload.
assert!(state.send_payloads.contains_key(&id));
}
#[test]
fn send_result_updates_only_the_matching_entry() {
let mut state = AppState::default();
let a = push_own(&mut state, "a");
state.submit_send(a, PendingSend::Text("a".to_string()));
let b = push_own(&mut state, "b");
state.submit_send(b, PendingSend::Text("b".to_string()));
state.apply_send_result(a, None);
assert_eq!(status_of(&state, a), Some(SendStatus::Broadcast));
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
// A result for an id with no matching entry is a harmless no-op.
state.apply_send_result(9999, None);
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
}
#[test]
fn send_result_after_eviction_drops_orphan_payload() {
let mut state = AppState::default();
let id = push_own(&mut state, "gone");
state.submit_send(id, PendingSend::Text("gone".to_string()));
// Simulate the entry leaving history (byte-budget eviction) with its
// send still in flight.
state
.chat_messages
.retain(|m| m.local_send.as_ref().map(|s| s.id) != Some(id));
state.apply_send_result(id, Some("dead".to_string()));
// No entry to mark → the payload must not leak.
assert!(!state.send_payloads.contains_key(&id));
}
#[test]
fn send_result_after_room_reset_is_a_noop() {
let mut state = AppState::default();
let id = push_own(&mut state, "before");
state.submit_send(id, PendingSend::Text("before".to_string()));
state.reset_room_state();
assert!(state.chat_messages.is_empty());
assert!(state.send_queue.is_empty());
assert!(state.send_payloads.is_empty());
// A late result for the pre-reset send touches nothing and adds no entry.
state.apply_send_result(id, None);
assert!(state.chat_messages.is_empty());
assert!(state.send_payloads.is_empty());
}
#[test]
fn fast_burst_dispatches_the_budget_then_queues() {
let mut state = AppState::default();
let mut ids = Vec::new();
// Nine near-instant sends: the pacer grants exactly the 8-message burst
// (sub-millisecond elapsed refills nothing), so the 9th must queue.
for _ in 0..9 {
let id = push_own(&mut state, "m");
state.submit_send(id, PendingSend::Text("m".to_string()));
ids.push(id);
}
for &id in &ids[..8] {
assert_eq!(status_of(&state, id), Some(SendStatus::Pending));
}
assert_eq!(status_of(&state, ids[8]), Some(SendStatus::Queued));
assert_eq!(state.send_queue, VecDeque::from([ids[8]]));
// Draining once the pacer has refilled (the ChatSendTick path) releases
// the queued message and dispatches it.
let now = state.send_now_ms() + 2000;
let ready = sendqueue::release_ready(&mut state.send_queue, &mut state.send_pacer, now);
for id in ready {
let payload = state.send_payloads.get(&id).cloned().unwrap();
state.dispatch_send(id, &payload);
}
assert!(state.send_queue.is_empty());
assert_eq!(status_of(&state, ids[8]), Some(SendStatus::Pending));
}
#[test]
fn retry_redispatches_only_the_targeted_send() {
let mut state = AppState::default();
let a = push_own(&mut state, "a");
state.submit_send(a, PendingSend::Text("a".to_string()));
let b = push_own(&mut state, "b");
state.submit_send(b, PendingSend::Text("b".to_string()));
// Both fail.
state.apply_send_result(a, Some("x".to_string()));
state.apply_send_result(b, Some("x".to_string()));
// Retry a: dispatched or requeued; b is untouched and still Failed.
if !state.send_queue.contains(&a)
&& let Some(payload) = state.send_payloads.get(&a).cloned()
{
state.submit_send(a, payload);
}
assert!(matches!(
status_of(&state, a),
Some(SendStatus::Pending) | Some(SendStatus::Queued)
));
assert_eq!(
status_of(&state, b),
Some(SendStatus::Failed("x".to_string()))
);
assert!(state.send_payloads.contains_key(&b));
}
} }
+129
View File
@@ -0,0 +1,129 @@
//! 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));
}
}
}
+35 -6
View File
@@ -66,12 +66,21 @@ pub enum CoreCommand {
/// Set what a recording captures (mixed / per-peer stems / both). Takes /// Set what a recording captures (mixed / per-peer stems / both). Takes
/// effect on the next recording start. Sent at startup from config. /// effect on the next recording start. Sent at startup from config.
SetRecordingMode(RecordingMode), SetRecordingMode(RecordingMode),
/// Broadcast a room text-chat message. No-op when not in a call. /// Broadcast a room text-chat message. `local_id` is the app's local-only
SendChat(String), /// handle for this send — it never goes on the wire; the core echoes it back
/// in [`UiEvent::ChatSendResult`] so the UI can mark the matching local echo
/// honestly (chat-hardening Phase 5). Not being in a call is a FAILURE
/// result, not a silent no-op.
SendChat {
local_id: u64,
text: String,
},
/// Send a chat message carrying a file attachment. The app has already read + /// Send a chat message carrying a file attachment. The app has already read +
/// capped the file and built the descriptor; core makes the bytes available /// capped the file and built the descriptor; core makes the bytes available
/// on the file plane and broadcasts the descriptor. /// on the file plane and broadcasts the descriptor. `local_id` as in
/// [`CoreCommand::SendChat`].
SendChatFile { SendChatFile {
local_id: u64,
text: String, text: String,
attachment: crate::files::ChatAttachment, attachment: crate::files::ChatAttachment,
/// Shared, not owned: the same allocation is retained by the UI cache /// Shared, not owned: the same allocation is retained by the UI cache
@@ -227,8 +236,12 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
| CoreCommand::SetAudioProfile(_) | CoreCommand::SetAudioProfile(_)
| CoreCommand::SetRecording(_) | CoreCommand::SetRecording(_)
| CoreCommand::SetRecordingMode(_) | CoreCommand::SetRecordingMode(_)
| CoreCommand::SendChat(_) | CoreCommand::SendChat {
local_id: _,
text: _,
}
| CoreCommand::SendChatFile { | CoreCommand::SendChatFile {
local_id: _,
text: _, text: _,
attachment: _, attachment: _,
data: _, data: _,
@@ -315,8 +328,12 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
| CoreCommand::SetAudioProfile(_) | CoreCommand::SetAudioProfile(_)
| CoreCommand::SetRecording(_) | CoreCommand::SetRecording(_)
| CoreCommand::SetRecordingMode(_) | CoreCommand::SetRecordingMode(_)
| CoreCommand::SendChat(_) | CoreCommand::SendChat {
local_id: _,
text: _,
}
| CoreCommand::SendChatFile { | CoreCommand::SendChatFile {
local_id: _,
text: _, text: _,
attachment: _, attachment: _,
data: _, data: _,
@@ -420,6 +437,15 @@ pub enum UiEvent {
RecordingStopped { RecordingStopped {
path: String, path: String,
}, },
/// The outcome of one locally initiated chat send (chat-hardening Phase 5).
/// `error = None` means our signed broadcast was handed to the gossip swarm
/// — deliberately NOT a delivery/read receipt; PeerSpeak has no peer
/// acknowledgements. `local_id` is the app's own handle from the
/// `SendChat`/`SendChatFile` command and never appears on the wire.
ChatSendResult {
local_id: u64,
error: Option<String>,
},
/// A room text-chat message arrived from a peer (never our own — local /// A room text-chat message arrived from a peer (never our own — local
/// messages are echoed by the UI on send). `from` is the sender's node id /// messages are echoed by the UI on send). `from` is the sender's node id
/// string, used to key their avatar (W4). /// string, used to key their avatar (W4).
@@ -616,7 +642,10 @@ mod tests {
CoreCommand::SetPeerMuted(peer, true), CoreCommand::SetPeerMuted(peer, true),
CoreCommand::SetPresenceMode(PresenceMode::Normal), CoreCommand::SetPresenceMode(PresenceMode::Normal),
CoreCommand::SetAudioProfile(crate::config::AudioProfile::BadNetwork), CoreCommand::SetAudioProfile(crate::config::AudioProfile::BadNetwork),
CoreCommand::SendChat("hello".to_string()), CoreCommand::SendChat {
local_id: 1,
text: "hello".to_string(),
},
]; ];
for cmd in commands { for cmd in commands {
+37 -10
View File
@@ -3259,27 +3259,54 @@ async fn run_core_loop(
} }
} }
CoreCommand::SendChat(text) => { CoreCommand::SendChat { local_id, text } => {
if let Some(session) = &active_session // Honest result either way (Phase 5): no active session is a
&& let Err(e) = session.room_state.send_chat(text, None).await // FAILURE the sender must see, not a silent drop.
{ let error = match &active_session {
Some(session) => session
.room_state
.send_chat(text, None)
.await
.err()
.map(|e| e.to_string()),
None => Some("not in a room".to_string()),
};
if let Some(e) = &error {
crate::log_msg(&format!("Failed to send chat: {e}")); crate::log_msg(&format!("Failed to send chat: {e}"));
} }
let _ = ui_tx
.send(UiEvent::ChatSendResult { local_id, error })
.await;
} }
CoreCommand::SendChatFile { CoreCommand::SendChatFile {
local_id,
text, text,
attachment, attachment,
data, data,
} => { } => {
if let Some(session) = &active_session { let error = match &active_session {
// Make the bytes fetchable by room members, then broadcast the Some(session) => {
// descriptor alongside the (possibly empty) caption text. // Make the bytes fetchable by room members, then broadcast
session.transport.serve_attachment(attachment.id, data); // the descriptor alongside the (possibly empty) caption
if let Err(e) = session.room_state.send_chat(text, Some(attachment)).await { // text. Re-serving the same id on a retry REPLACES the
crate::log_msg(&format!("Failed to send chat file: {e}")); // store entry (same Arc), never double-counts it.
session.transport.serve_attachment(attachment.id, data);
session
.room_state
.send_chat(text, Some(attachment))
.await
.err()
.map(|e| e.to_string())
} }
None => Some("not in a room".to_string()),
};
if let Some(e) = &error {
crate::log_msg(&format!("Failed to send chat file: {e}"));
} }
let _ = ui_tx
.send(UiEvent::ChatSendResult { local_id, error })
.await;
} }
CoreCommand::FetchAttachment { from, attachment } => { CoreCommand::FetchAttachment { from, attachment } => {
+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 /// 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. /// is well above real human chat rate while bounding a flooder to a trickle.
const CHAT_AUTHOR_BURST: f64 = 8.0; /// `pub(crate)` because the sender-side pacer (chat-hardening Phase 5) mirrors
const CHAT_AUTHOR_REFILL_PER_MS: f64 = 1.0 / 1000.0; /// 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 /// 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. /// 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; const CHAT_REJECT_LOG_COOLDOWN_MS: u64 = 10_000;
/// A minimal deterministic token bucket: time is passed in, never read from a /// 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)] #[derive(Debug, Clone, Copy)]
struct TokenBucket { pub(crate) struct TokenBucket {
tokens: f64, tokens: f64,
last_ms: u64, last_ms: u64,
} }
impl TokenBucket { impl TokenBucket {
fn full(burst: f64, now_ms: u64) -> Self { pub(crate) fn full(burst: f64, now_ms: u64) -> Self {
Self { Self {
tokens: burst, tokens: burst,
last_ms: now_ms, last_ms: now_ms,
@@ -307,7 +310,7 @@ impl TokenBucket {
/// Refill for elapsed time (capped at `burst`), then take one token if /// Refill for elapsed time (capped at `burst`), then take one token if
/// available. Returns whether a token was consumed. /// 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; let elapsed = now_ms.saturating_sub(self.last_ms) as f64;
self.tokens = (self.tokens + elapsed * refill_per_ms).min(burst); self.tokens = (self.tokens + elapsed * refill_per_ms).min(burst);
self.last_ms = now_ms; self.last_ms = now_ms;
@@ -1276,26 +1279,29 @@ impl RoomState for IrohGossipState {
let sender_opt = self.active_sender.lock().unwrap().clone(); let sender_opt = self.active_sender.lock().unwrap().clone();
let topic_opt = *self.active_topic_bytes.lock().unwrap(); let topic_opt = *self.active_topic_bytes.lock().unwrap();
if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) { // A missing sender/topic or an encode failure is a real send failure the
let payload = sign_gossip( // caller must see (chat-hardening Phase 5) — silently returning Ok here
&self.secret_key, // would let the UI present an unsent message as broadcast.
&topic, 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, ts,
GossipMessage::Chat { attachment,
name, },
text, );
ts, let bytes = serde_json::to_vec(&payload)
attachment, .map_err(|e| NetError::Other(format!("Failed to encode chat: {e}")))?;
}, sender
); .broadcast(bytes.into())
if let Ok(bytes) = serde_json::to_vec(&payload) { .await
sender .map_err(|e| NetError::Gossip(e.to_string()))
.broadcast(bytes.into())
.await
.map_err(|e| NetError::Gossip(e.to_string()))?;
}
}
Ok(())
} }
async fn leave(&self) -> Result<(), NetError> { async fn leave(&self) -> Result<(), NetError> {