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:
+86
-26
@@ -1,18 +1,23 @@
|
|||||||
# Chat hardening — ephemeral implementation plan
|
# Chat hardening — ephemeral implementation plan
|
||||||
|
|
||||||
**Status (2026-07-18):** Phases 1–4 COMPLETE. Phase 1 = shared text policy in
|
**Status (2026-07-18):** Phases 1–5 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
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user