CI / check (push) Successful in 2m33s
Phase 3 of docs/chat-hardening-plan.md — attachments can no longer turn into unbounded memory, bandwidth, decoder, or task pressure (S15 closed; S14's filename half closed). Cache and image cost (3A): AttachmentCache now carries encoded- and decoded-byte budgets (96 MiB / 64 MiB) on top of the count cap, with per-entry weights, replacement accounting, and oldest-first eviction; an individually over-budget fetch services any pending Save/Play from the bytes in hand and is exposed as Evicted instead of retained. validate_image_bytes prechecks header dimensions (per-side AND a new 14 MP total-pixel limit) before any decode; the renderer only ever receives a ≤1600 px downscaled RGBA preview whose w*h*4 cost counts against the decoded budget — originals stay encoded-only for Save. sanitize_filename strips the bidi/zero-width spoofing set (RTL-override extension spoof). Download policy and state (3B): images auto-fetch only when roster- authored AND declared ≤4 MiB, gated by a new deterministic AutoFetchBudget (per-author and session request+byte token buckets, check-then-take, bounded author map) alongside the existing dedup and four-permit bound. Attachment state is now explicit — absence/Loading/ Ready/Failed/Evicted — driven by a new AttachmentFetchStarted event, so skipped or evicted images render a "Load image" button instead of an indefinite "loading…", and repeated clicks can never spawn duplicate fetch tasks. Exact transfers and serve store (3C): fetch_blob requires the received length to equal the declared size (short = local error, overlong = bounded-read reject, empty keeps meaning "sender no longer has it"); the file picker's unbounded read is replaced by a metadata-prechecked cap+1 bounded reader; one Arc<Vec<u8>> now backs the UI cache, command queue, and serve store; served_files is a count- and byte-budgeted FIFO ServeStore (16 entries / 128 MiB). 37 new tests (568 lib total) including a real two-endpoint loopback exercising exact/short/overlong/unknown-id transfers. Plan checkboxes ticked and constant deviations decision-logged. Tests-green-only: the plan's two-machine field-test section remains open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
253 lines
9.3 KiB
Rust
253 lines
9.3 KiB
Rust
//! Byte/request budgets for AUTOMATIC chat-attachment fetches (Phase 3B).
|
||
//!
|
||
//! The four-permit semaphore bounds how many auto-fetch tasks run at once, but
|
||
//! not how much a peer can make us download over time: with permits released
|
||
//! after each transfer, an insider could stream distinct ≤4 MiB images
|
||
//! sequentially forever. This budget adds per-author and session (room-wide)
|
||
//! token buckets over both request COUNT and declared BYTES. Like the Phase 2
|
||
//! chat gate, time is passed in — never read from a clock — so every refill
|
||
//! boundary is unit-testable.
|
||
//!
|
||
//! Only the automatic path consults this; a user's explicit click (Save /
|
||
//! Download / Load image) is human-rate-limited and always allowed through to
|
||
//! the fetch (still subject to the transfer cap and cache/decoder budgets).
|
||
|
||
use iroh::EndpointId;
|
||
use std::collections::HashMap;
|
||
|
||
/// Per-author request burst: how many auto-fetches one author can trigger
|
||
/// back-to-back before refill pacing binds.
|
||
pub const AUTHOR_REQ_BURST: f64 = 8.0;
|
||
/// Per-author request refill: one recovered every 10 s.
|
||
pub const AUTHOR_REQ_REFILL_PER_MS: f64 = 1.0 / 10_000.0;
|
||
/// Per-author byte burst (declared sizes): a couple of full-size auto images
|
||
/// plus a normal working set.
|
||
pub const AUTHOR_BYTES_BURST: f64 = (16 * 1024 * 1024) as f64;
|
||
/// Per-author byte refill: 64 KiB/s (~one 4 MiB auto image per minute).
|
||
pub const AUTHOR_BYTES_REFILL_PER_MS: f64 = (64 * 1024) as f64 / 1000.0;
|
||
|
||
/// Session-wide request burst across all authors.
|
||
pub const SESSION_REQ_BURST: f64 = 16.0;
|
||
/// Session-wide request refill: one recovered every 5 s.
|
||
pub const SESSION_REQ_REFILL_PER_MS: f64 = 1.0 / 5_000.0;
|
||
/// Session-wide byte burst across all authors.
|
||
pub const SESSION_BYTES_BURST: f64 = (48 * 1024 * 1024) as f64;
|
||
/// Session-wide byte refill: 128 KiB/s.
|
||
pub const SESSION_BYTES_REFILL_PER_MS: f64 = (128 * 1024) as f64 / 1000.0;
|
||
|
||
/// Bound on the per-author bucket map. Authors are roster members (≤32 live),
|
||
/// so this tracks the roster plus recently departed; the least-recently-active
|
||
/// entry is pruned past the cap.
|
||
pub const AUTHOR_MAP_CAP: usize = 64;
|
||
|
||
/// A deterministic token bucket that can take a WEIGHTED cost (bytes), unlike
|
||
/// the unit-cost bucket in the gossip chat gate.
|
||
#[derive(Debug, Clone, Copy)]
|
||
struct WeightedBucket {
|
||
tokens: f64,
|
||
last_ms: u64,
|
||
}
|
||
|
||
impl WeightedBucket {
|
||
fn full(burst: f64, now_ms: u64) -> Self {
|
||
Self {
|
||
tokens: burst,
|
||
last_ms: now_ms,
|
||
}
|
||
}
|
||
|
||
/// Refill for elapsed time (capped at `burst`) without consuming.
|
||
fn refill(&mut self, burst: f64, refill_per_ms: f64, now_ms: u64) {
|
||
let elapsed = now_ms.saturating_sub(self.last_ms) as f64;
|
||
self.tokens = (self.tokens + elapsed * refill_per_ms).min(burst);
|
||
self.last_ms = now_ms;
|
||
}
|
||
|
||
fn has(&self, cost: f64) -> bool {
|
||
self.tokens >= cost
|
||
}
|
||
|
||
fn take(&mut self, cost: f64) {
|
||
self.tokens -= cost;
|
||
}
|
||
}
|
||
|
||
/// One author's pair of buckets plus last activity (for idle pruning).
|
||
#[derive(Debug)]
|
||
struct AuthorBudget {
|
||
reqs: WeightedBucket,
|
||
bytes: WeightedBucket,
|
||
last_seen_ms: u64,
|
||
}
|
||
|
||
/// Admission budget for automatic attachment fetches. All four buckets are
|
||
/// checked BEFORE any is consumed, so a rejection never burns tokens (no
|
||
/// refund bookkeeping — the check-then-take is atomic within `admit`).
|
||
#[derive(Debug)]
|
||
pub struct AutoFetchBudget {
|
||
session_reqs: WeightedBucket,
|
||
session_bytes: WeightedBucket,
|
||
authors: HashMap<EndpointId, AuthorBudget>,
|
||
}
|
||
|
||
impl AutoFetchBudget {
|
||
pub fn new(now_ms: u64) -> Self {
|
||
Self {
|
||
session_reqs: WeightedBucket::full(SESSION_REQ_BURST, now_ms),
|
||
session_bytes: WeightedBucket::full(SESSION_BYTES_BURST, now_ms),
|
||
authors: HashMap::new(),
|
||
}
|
||
}
|
||
|
||
/// Whether an auto-fetch of `size` declared bytes for `author` may start
|
||
/// now. Consumes one request token and `size` byte tokens from BOTH the
|
||
/// author's and the session's buckets — or nothing at all on rejection.
|
||
pub fn admit(&mut self, author: EndpointId, size: u64, now_ms: u64) -> bool {
|
||
self.prune(author, now_ms);
|
||
let entry = self.authors.entry(author).or_insert_with(|| AuthorBudget {
|
||
reqs: WeightedBucket::full(AUTHOR_REQ_BURST, now_ms),
|
||
bytes: WeightedBucket::full(AUTHOR_BYTES_BURST, now_ms),
|
||
last_seen_ms: now_ms,
|
||
});
|
||
entry.last_seen_ms = now_ms;
|
||
entry
|
||
.reqs
|
||
.refill(AUTHOR_REQ_BURST, AUTHOR_REQ_REFILL_PER_MS, now_ms);
|
||
entry
|
||
.bytes
|
||
.refill(AUTHOR_BYTES_BURST, AUTHOR_BYTES_REFILL_PER_MS, now_ms);
|
||
self.session_reqs
|
||
.refill(SESSION_REQ_BURST, SESSION_REQ_REFILL_PER_MS, now_ms);
|
||
self.session_bytes
|
||
.refill(SESSION_BYTES_BURST, SESSION_BYTES_REFILL_PER_MS, now_ms);
|
||
|
||
let cost = size as f64;
|
||
let ok = entry.reqs.has(1.0)
|
||
&& entry.bytes.has(cost)
|
||
&& self.session_reqs.has(1.0)
|
||
&& self.session_bytes.has(cost);
|
||
if ok {
|
||
let entry = self.authors.get_mut(&author).expect("just inserted");
|
||
entry.reqs.take(1.0);
|
||
entry.bytes.take(cost);
|
||
self.session_reqs.take(1.0);
|
||
self.session_bytes.take(cost);
|
||
}
|
||
ok
|
||
}
|
||
|
||
/// Keep the author map bounded: past the cap, drop the least-recently
|
||
/// active entry that isn't the author being admitted. A pruned author
|
||
/// returns with full buckets, but authors are roster-gated upstream, so
|
||
/// the map can't be churned by strangers.
|
||
fn prune(&mut self, keep: EndpointId, _now_ms: u64) {
|
||
while self.authors.len() >= AUTHOR_MAP_CAP {
|
||
let Some(victim) = self
|
||
.authors
|
||
.iter()
|
||
.filter(|(id, _)| **id != keep)
|
||
.min_by_key(|(_, b)| b.last_seen_ms)
|
||
.map(|(id, _)| *id)
|
||
else {
|
||
break;
|
||
};
|
||
self.authors.remove(&victim);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
fn author_count(&self) -> usize {
|
||
self.authors.len()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use iroh::SecretKey;
|
||
|
||
const T0: u64 = 1_000_000;
|
||
const MIB: u64 = 1024 * 1024;
|
||
|
||
fn author() -> EndpointId {
|
||
SecretKey::generate().public()
|
||
}
|
||
|
||
#[test]
|
||
fn author_request_burst_then_refill_recovers() {
|
||
let mut b = AutoFetchBudget::new(T0);
|
||
let a = author();
|
||
// Tiny sizes so only the REQUEST buckets can bind.
|
||
for _ in 0..AUTHOR_REQ_BURST as usize {
|
||
assert!(b.admit(a, 1, T0));
|
||
}
|
||
assert!(!b.admit(a, 1, T0), "author request burst exhausted");
|
||
// One request refills after 10 s.
|
||
assert!(b.admit(a, 1, T0 + 10_000));
|
||
assert!(!b.admit(a, 1, T0 + 10_000));
|
||
}
|
||
|
||
#[test]
|
||
fn author_byte_budget_binds_and_recovers() {
|
||
let mut b = AutoFetchBudget::new(T0);
|
||
let a = author();
|
||
// 4 × 4 MiB = the full 16 MiB author byte burst (well under the
|
||
// 8-request burst, so bytes are the binding constraint).
|
||
for _ in 0..4 {
|
||
assert!(b.admit(a, 4 * MIB, T0));
|
||
}
|
||
assert!(!b.admit(a, 4 * MIB, T0), "author byte burst exhausted");
|
||
// 64 KiB/s → a 4 MiB image is affordable again after 64 s (which also
|
||
// refills 6 request tokens, so bytes stay the binding constraint).
|
||
assert!(!b.admit(a, 4 * MIB, T0 + 32_000));
|
||
assert!(b.admit(a, 4 * MIB, T0 + 64_000));
|
||
}
|
||
|
||
#[test]
|
||
fn session_budget_binds_across_authors_without_burning_author_tokens() {
|
||
let mut b = AutoFetchBudget::new(T0);
|
||
// Three authors × 16 MiB exhausts the 48 MiB session byte burst even
|
||
// though each author is within their own budget.
|
||
for _ in 0..3 {
|
||
let a = author();
|
||
for _ in 0..4 {
|
||
assert!(b.admit(a, 4 * MIB, T0));
|
||
}
|
||
}
|
||
let fresh = author();
|
||
assert!(!b.admit(fresh, 4 * MIB, T0), "session bytes exhausted");
|
||
// The rejection consumed NOTHING: once the session refills enough for
|
||
// one image (4 MiB / 128 KiB/s = 32 s), the fresh author's own full
|
||
// burst is intact and admits immediately.
|
||
assert!(b.admit(fresh, 4 * MIB, T0 + 32_000));
|
||
}
|
||
|
||
#[test]
|
||
fn session_request_bucket_binds_across_authors() {
|
||
let mut b = AutoFetchBudget::new(T0);
|
||
// 16 tiny requests from distinct authors exhaust the session request
|
||
// burst while every author bucket stays nearly full.
|
||
for _ in 0..SESSION_REQ_BURST as usize {
|
||
assert!(b.admit(author(), 1, T0));
|
||
}
|
||
assert!(!b.admit(author(), 1, T0), "session requests exhausted");
|
||
assert!(b.admit(author(), 1, T0 + 5_000), "one recovers after 5 s");
|
||
}
|
||
|
||
#[test]
|
||
fn author_map_stays_bounded_pruning_least_recent() {
|
||
let mut b = AutoFetchBudget::new(T0);
|
||
// Session request refill would bind over a naive loop; space the
|
||
// admissions out so only the map bound is under test.
|
||
let mut t = T0;
|
||
let first = author();
|
||
assert!(b.admit(first, 1, t));
|
||
for _ in 0..(AUTHOR_MAP_CAP + 10) {
|
||
t += 10_000;
|
||
assert!(b.admit(author(), 1, t));
|
||
assert!(b.author_count() <= AUTHOR_MAP_CAP);
|
||
}
|
||
assert!(b.author_count() <= AUTHOR_MAP_CAP);
|
||
}
|
||
}
|