chat: attachment cache, download, and transfer hardening (Phase 3)
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>
This commit is contained in:
2026-07-18 02:09:30 -04:00
co-authored by Claude Fable 5
parent 8898652349
commit 554b613466
9 changed files with 1305 additions and 147 deletions
+442 -82
View File
@@ -321,13 +321,23 @@ struct ChatEntry {
attachment: Option<crate::files::ChatAttachment>,
}
/// Fetch state of a chat attachment's bytes (session-only).
/// Fetch state of a chat attachment's bytes (session-only). Absence from the
/// cache means NOT FETCHED (auto-fetch skipped or never attempted) — the UI
/// renders a Load/Download button for it, never a loading label (Phase 3B).
#[derive(Debug, Clone)]
enum AttachmentState {
/// Bytes in hand (image decoded-valid, or a file ready to save).
Ready(Vec<u8>),
/// A fetch task is running for this key (core announced it, or the user
/// clicked). Suppresses duplicate fetches on repeated clicks.
Loading,
/// Bytes in hand (image decoded-valid, or a file ready to save). Shared so
/// the cache, save tasks, and the serve path hold one allocation.
Ready(std::sync::Arc<Vec<u8>>),
/// Fetch or decode failed; carries a short reason for the UI.
Failed(String),
/// The bytes were fetched but could not be retained within the cache
/// budgets (or were dropped under budget pressure after any pending
/// save/play was serviced). Rendered like NotFetched: load-on-demand.
Evicted,
}
/// Identifies one fetched attachment by BOTH the authoring peer and the
@@ -341,13 +351,31 @@ type AttachmentKey = (EndpointId, crate::files::AttachmentId);
/// above any realistic on-screen image working set.
const ATTACHMENT_CACHE_CAP: usize = 64;
/// Budget for retained ENCODED attachment bytes (the original file bytes kept
/// for Save). The count cap alone would admit 64 × 25 MiB = 1.6 GiB; this keeps
/// the worst case at a few large attachments plus the normal small working set
/// (Phase 3A).
const ATTACHMENT_CACHE_ENCODED_BUDGET: usize = 96 * 1024 * 1024;
/// Budget for DECODED preview bytes (estimated RGBA cost `w*h*4` of each inline
/// preview, counted even though iced may copy/upload internally). Previews are
/// capped at 1600 px per side (≈10 MiB RGBA worst case), so this comfortably
/// covers the on-screen set while bounding an image-spam session (Phase 3A).
const ATTACHMENT_CACHE_DECODED_BUDGET: usize = 64 * 1024 * 1024;
/// One cached attachment result: its fetch state plus, for ready images, the
/// pre-built iced image handle (built once on arrival, not per redraw — the
/// e917c53 flicker fix). `handle` is `None` for files and failures.
///
/// `encoded` / `decoded` are this entry's weights against the cache byte
/// budgets: the retained encoded length and the preview's estimated RGBA cost.
/// Both are zero for non-`Ready` states.
#[derive(Debug, Clone)]
struct AttachmentEntry {
state: AttachmentState,
handle: Option<iced::widget::image::Handle>,
handle: Option<PreviewHandle>,
encoded: usize,
decoded: usize,
}
/// Bounded store of fetched chat-attachment results, keyed by [`AttachmentKey`].
@@ -367,38 +395,100 @@ struct AttachmentCache {
/// exactly the present keys (one entry each), so it is bounded by `cap`.
order: VecDeque<AttachmentKey>,
cap: usize,
/// Byte budgets and their running totals (Phase 3A). Totals are exactly the
/// sum of the present entries' weights — every remove/replace subtracts the
/// old weights before new ones are counted.
encoded_budget: usize,
decoded_budget: usize,
encoded_total: usize,
decoded_total: usize,
}
impl AttachmentCache {
fn new(cap: usize) -> Self {
fn new(cap: usize, encoded_budget: usize, decoded_budget: usize) -> Self {
Self {
entries: HashMap::new(),
order: VecDeque::new(),
cap: cap.max(1),
encoded_budget,
decoded_budget,
encoded_total: 0,
decoded_total: 0,
}
}
/// Insert or replace an entry. A brand-new key evicts the oldest entries
/// until there is room; replacing an existing key keeps its position (and so
/// its age), only updating the value.
/// Insert or replace an entry, keeping the count cap AND both byte budgets
/// satisfied by evicting oldest entries first. Replacing an existing key
/// keeps its position (and so its age); its old weights are subtracted
/// before the new ones are checked.
///
/// Returns `false` if the entry ALONE exceeds a byte budget: it is not
/// retained (and an existing entry under the key is dropped), leaving no
/// entry for `key`. The caller services any immediate need from the bytes
/// in hand and stores an [`AttachmentState::Evicted`] marker instead.
fn insert(
&mut self,
key: AttachmentKey,
state: AttachmentState,
handle: Option<iced::widget::image::Handle>,
) {
if !self.entries.contains_key(&key) {
while self.entries.len() >= self.cap {
match self.order.pop_front() {
Some(old) => {
self.entries.remove(&old);
}
None => break,
}
handle: Option<PreviewHandle>,
) -> bool {
let encoded = match &state {
AttachmentState::Ready(data) => data.len(),
_ => 0,
};
let decoded = handle_decoded_cost(&state, &handle);
// Replacement: subtract the old weights first so they don't count
// against the new entry's fit.
if let Some(old) = self.entries.get(&key) {
self.encoded_total -= old.encoded;
self.decoded_total -= old.decoded;
}
if encoded > self.encoded_budget || decoded > self.decoded_budget {
// Individually overweight: never retained, budgets never exceeded.
if self.entries.remove(&key).is_some() {
self.order.retain(|k| k != &key);
}
return false;
}
let replacing = self.entries.contains_key(&key);
// Evict oldest (skipping the key being replaced, which keeps its slot)
// until the count cap and both budgets accommodate the new entry.
while self.would_overflow(replacing, encoded, decoded) {
let Some(victim) = self.order.iter().find(|k| **k != key).copied() else {
break;
};
self.remove_entry(&victim);
}
if !replacing {
self.order.push_back(key);
}
self.entries.insert(key, AttachmentEntry { state, handle });
self.encoded_total += encoded;
self.decoded_total += decoded;
self.entries.insert(
key,
AttachmentEntry {
state,
handle,
encoded,
decoded,
},
);
true
}
fn would_overflow(&self, replacing: bool, encoded: usize, decoded: usize) -> bool {
let count_full = !replacing && self.entries.len() >= self.cap;
count_full
|| self.encoded_total + encoded > self.encoded_budget
|| self.decoded_total + decoded > self.decoded_budget
}
fn remove_entry(&mut self, key: &AttachmentKey) {
if let Some(old) = self.entries.remove(key) {
self.encoded_total -= old.encoded;
self.decoded_total -= old.decoded;
self.order.retain(|k| k != key);
}
}
fn get(&self, key: &AttachmentKey) -> Option<&AttachmentState> {
@@ -406,7 +496,9 @@ impl AttachmentCache {
}
fn handle(&self, key: &AttachmentKey) -> Option<&iced::widget::image::Handle> {
self.entries.get(key).and_then(|e| e.handle.as_ref())
self.entries
.get(key)
.and_then(|e| e.handle.as_ref().map(|h| &h.handle))
}
fn is_ready(&self, key: &AttachmentKey) -> bool {
@@ -419,9 +511,16 @@ impl AttachmentCache {
)
}
/// Whether a fetch for this key is already running (dedup for clicks).
fn is_loading(&self, key: &AttachmentKey) -> bool {
matches!(self.get(key), Some(AttachmentState::Loading))
}
fn clear(&mut self) {
self.entries.clear();
self.order.clear();
self.encoded_total = 0;
self.decoded_total = 0;
}
#[cfg(test)]
@@ -430,6 +529,38 @@ impl AttachmentCache {
}
}
/// The decoded-budget weight of an entry: the preview's RGBA estimate for a
/// ready image (`w*h*4` via the handle's known preview dimensions), else zero.
/// The handle is built from the downscaled preview, so its dimensions are the
/// preview's — but iced's `Handle` doesn't expose them, so the caller threads
/// the cost through [`PreviewHandle`].
fn handle_decoded_cost(state: &AttachmentState, handle: &Option<PreviewHandle>) -> usize {
match (state, handle) {
(AttachmentState::Ready(_), Some(h)) => h.decoded_cost,
_ => 0,
}
}
/// An iced image handle built from a downscaled preview, paired with the
/// preview's decoded RGBA cost (iced doesn't expose handle dimensions).
#[derive(Debug, Clone)]
struct PreviewHandle {
handle: iced::widget::image::Handle,
decoded_cost: usize,
}
/// Build the inline preview handle (and its decoded weight) for image bytes, or
/// `None` when the bytes don't validate as a displayable image. The renderer
/// only ever receives the ≤1600 px downscale; originals stay encoded-only.
fn build_preview_handle(bytes: &[u8]) -> Option<PreviewHandle> {
let p = crate::files::decode_preview(bytes)?;
let decoded_cost = crate::files::preview_rgba_cost(p.width, p.height);
Some(PreviewHandle {
handle: iced::widget::image::Handle::from_rgba(p.width, p.height, p.rgba),
decoded_cost,
})
}
/// Cap on retained chat history so a long call can't grow it without bound.
const CHAT_HISTORY_MAX: usize = 300;
@@ -612,11 +743,17 @@ pub enum AppMessage {
/// Open the native picker to attach a file to the chat.
PickAttachmentFile,
/// Result of the attach picker: (filename, bytes), or None if cancelled.
AttachmentFilePicked(Option<(String, Vec<u8>)>),
/// Picker result: `None` = cancelled; `Some((name, None))` = file over the
/// attachment cap or unreadable; `Some((name, Some(bytes)))` = bytes in hand.
AttachmentFilePicked(Option<(String, Option<Vec<u8>>)>),
/// Save (downloading first if needed) a received attachment to disk. Carries
/// the full `(author, id)` key so the correct sender's bytes are fetched and
/// saved even if another peer reused the same attachment id (Tier C F-12).
SaveAttachment(AttachmentKey),
/// Fetch an image that wasn't auto-fetched (over the auto-size cap, budget-
/// skipped, or evicted) for inline display (Phase 3B). Manual, so it may use
/// the full attachment cap; duplicates are suppressed via the Loading state.
LoadImage(AttachmentKey),
/// Open the click-to-enlarge image lightbox for this attachment (author + id).
OpenImageLightbox(AttachmentKey),
/// Close the image lightbox overlay.
@@ -1220,7 +1357,11 @@ impl Default for AppState {
recording: false,
recording_started: None,
chat_messages: Vec::new(),
attachments: AttachmentCache::new(ATTACHMENT_CACHE_CAP),
attachments: AttachmentCache::new(
ATTACHMENT_CACHE_CAP,
ATTACHMENT_CACHE_ENCODED_BUDGET,
ATTACHMENT_CACHE_DECODED_BUDGET,
),
image_lightbox: None,
pending_saves: HashSet::new(),
pending_plays: HashSet::new(),
@@ -2021,25 +2162,40 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
}
}
UiEvent::AttachmentReady { from, id, data } => {
// Bytes arrived for this specific (author, id). For images we
// can cache the iced handle now (built once, not per redraw).
// If the user was waiting to save this file, the save dialog
// is opened from update() below by checking pending_saves —
// done lazily so this arm stays simple.
// Bytes arrived for this specific (author, id). For images
// the ≤1600px preview handle is built once here (not per
// redraw); only its downscale ever reaches the renderer.
let key = (from, id);
let handle = crate::files::validate_image_bytes(&data)
.is_some()
.then(|| iced::widget::image::Handle::from_bytes(data.clone()));
let handle = build_preview_handle(&data);
let needs_save = state.pending_saves.remove(&key);
let needs_play = state.pending_plays.remove(&id);
state
.attachments
.insert(key, AttachmentState::Ready(data), handle);
let retained =
state
.attachments
.insert(key, AttachmentState::Ready(data.clone()), handle);
if !retained {
// Individually over-budget: any immediate save/play is
// serviced from the bytes in hand below, and the entry
// is exposed as evicted, never exceeding the budgets.
state
.attachments
.insert(key, AttachmentState::Evicted, None);
}
if needs_play {
play_ready_audio(state, key);
play_audio_data(state, id, &data);
}
if needs_save {
return save_attachment_task(state, key);
return save_attachment_data_task(state, key, data);
}
}
UiEvent::AttachmentFetchStarted { from, id } => {
// A fetch task exists for this key; show a real loading
// state (and dedup further clicks). Never demote Ready.
let key = (from, id);
if !state.attachments.is_ready(&key) {
state
.attachments
.insert(key, AttachmentState::Loading, None);
}
}
UiEvent::AttachmentFailed { from, id, error } => {
@@ -2933,7 +3089,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
}
}
AppMessage::PickAttachmentFile => {
// Native picker off the UI thread; returns (filename, bytes).
// Native picker off the UI thread; returns (filename, bytes). The
// read is BOUNDED (metadata precheck + cap+1 read, Phase 3C) — an
// oversized pick reports `(name, None)` without buffering the file.
return Task::perform(
async {
let handle = rfd::AsyncFileDialog::new()
@@ -2941,7 +3099,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
.pick_file()
.await;
match handle {
Some(h) => Some((h.file_name(), h.read().await)),
Some(h) => {
let name = h.file_name();
let path = h.path().to_path_buf();
let bytes = tokio::task::spawn_blocking(move || {
crate::files::read_file_capped(&path)
})
.await
.ok()
.and_then(|r| r.ok())
.flatten();
Some((name, bytes))
}
None => None,
}
},
@@ -2950,6 +3119,13 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
}
AppMessage::AttachmentFilePicked(picked) => {
if let Some((name, bytes)) = picked {
let Some(bytes) = bytes else {
state.status_message = format!(
"Can't attach {name} — unreadable or larger than {}.",
crate::files::human_size(crate::files::MAX_ATTACHMENT_BYTES)
);
return Task::none();
};
let size = bytes.len() as u64;
if !crate::files::size_within_cap(size) {
state.status_message = format!(
@@ -2958,6 +3134,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
);
return Task::none();
}
let bytes = Arc::new(bytes);
let kind = crate::files::classify(&bytes);
// Random 32-byte handle for this attachment.
let id: crate::files::AttachmentId = rand::random();
@@ -2970,12 +3147,13 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// Keep our own bytes locally so we see our own attachment inline
// immediately (others fetch it off the file plane). Cache under
// our own (author, id) key so the render path — which keys the
// line by `from` = self_id — finds them.
// line by `from` = self_id — finds them. The same Arc backs the
// cache, the command queue, and the serve store (Phase 3C).
if let Ok(self_eid) = state.self_id.parse::<EndpointId>() {
let key = (self_eid, id);
let handle = (kind == crate::files::AttachmentKind::Image
&& crate::files::validate_image_bytes(&bytes).is_some())
.then(|| iced::widget::image::Handle::from_bytes(bytes.clone()));
let handle = (kind == crate::files::AttachmentKind::Image)
.then(|| build_preview_handle(&bytes))
.flatten();
state
.attachments
.insert(key, AttachmentState::Ready(bytes.clone()), handle);
@@ -2998,13 +3176,20 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
}
}
AppMessage::SaveAttachment(key) => {
// If we already have the bytes, save now; otherwise fetch from the
// sender and save when AttachmentReady arrives (pending_saves). The
// key's author half is the exact sender of the clicked line.
// If we already have the bytes, save now; if a fetch is already
// running, just record the save intent (no duplicate fetch task);
// otherwise fetch from the sender and save when AttachmentReady
// arrives (pending_saves). The key's author half is the exact
// sender of the clicked line.
if state.attachments.is_ready(&key) {
return save_attachment_task(state, key);
} else if state.attachments.is_loading(&key) {
state.pending_saves.insert(key);
} else if let Some(att) = find_attachment_source(state, key) {
state.pending_saves.insert(key);
state
.attachments
.insert(key, AttachmentState::Loading, None);
state.status_message = format!("Downloading {}", att.name);
let _ = state.controller.send(CoreCommand::FetchAttachment {
from: key.0,
@@ -3012,6 +3197,23 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
});
}
}
AppMessage::LoadImage(key) => {
// Manual inline-image fetch for a skipped/evicted image. The
// Loading state both drives the label and suppresses duplicates.
if !state.attachments.is_ready(&key)
&& !state.attachments.is_loading(&key)
&& let Some(att) = find_attachment_source(state, key)
{
state
.attachments
.insert(key, AttachmentState::Loading, None);
state.status_message = format!("Loading {}", att.name);
let _ = state.controller.send(CoreCommand::FetchAttachment {
from: key.0,
attachment: att,
});
}
}
AppMessage::AttachmentSaved(msg) => {
if let Some(m) = msg {
state.status_message = m;
@@ -3020,11 +3222,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::PlayAudio(key) => {
if state.attachments.is_ready(&key) {
play_ready_audio(state, key);
} else if state.attachments.is_loading(&key) {
// A fetch is already running (e.g. a Save click); just record
// the play intent for when the bytes arrive.
state.pending_plays.insert(key.1);
} else if let Some(att) = find_attachment_source(state, key) {
// Repeated clicks while the transfer is pending must not launch
// duplicate fetches. (pending_plays is id-keyed — it's coupled to
// the id-keyed clip player; same-id collisions are cosmetic.)
if state.pending_plays.insert(key.1) {
state
.attachments
.insert(key, AttachmentState::Loading, None);
state.status_message = format!("Loading {}", att.name);
let _ = state.controller.send(CoreCommand::FetchAttachment {
from: key.0,
@@ -3650,9 +3859,17 @@ fn play_ready_audio(state: &mut AppState, key: AttachmentKey) {
let Some(AttachmentState::Ready(data)) = state.attachments.get(&key) else {
return;
};
let id = key.1;
let data = data.clone();
play_audio_data(state, key.1, &data);
}
/// Play audio from bytes in hand — used both for cached entries and for a
/// just-fetched attachment too large to retain (Phase 3A overweight path).
fn play_audio_data(state: &mut AppState, id: crate::files::AttachmentId, data: &Arc<Vec<u8>>) {
if crate::files::is_probably_audio(data) {
let bytes = data.clone();
// The clip player's command channel owns its bytes; one copy here at
// the click, the cache keeps the shared original.
let bytes = data.as_ref().clone();
state.invalid_audio.remove(&id);
state.clip_player.play(id, bytes);
// Apply this clip's effective gain; the command lands after Play so it
@@ -4065,6 +4282,16 @@ fn save_attachment_task(state: &AppState, key: AttachmentKey) -> Task<AppMessage
return Task::none();
};
let data = data.clone();
save_attachment_data_task(state, key, data)
}
/// The dialog+write half of a save, from bytes in hand — also used when a
/// just-fetched attachment was too large to retain in the cache (Phase 3A).
fn save_attachment_data_task(
state: &AppState,
key: AttachmentKey,
data: Arc<Vec<u8>>,
) -> Task<AppMessage> {
let default_name = attachment_default_name(&state.chat_messages, key);
Task::perform(
async move {
@@ -4074,7 +4301,7 @@ fn save_attachment_task(state: &AppState, key: AttachmentKey) -> Task<AppMessage
.save_file()
.await;
match handle {
Some(h) => match std::fs::write(h.path(), &data) {
Some(h) => match std::fs::write(h.path(), data.as_slice()) {
Ok(()) => Some(format!("Saved {}", h.path().display())),
Err(e) => Some(format!("Save failed: {e}")),
},
@@ -7124,10 +7351,33 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
None => img.into(),
}
}
None => text(format!("🖼 {} — loading…", att.name))
// "loading…" ONLY while a fetch task actually runs
// (Phase 3B). Absent/evicted images — over the
// auto-fetch cap, budget-skipped, or dropped by the
// cache — get an explicit Load button instead of an
// indefinite label.
None if matches!(data, Some(AttachmentState::Loading)) => {
text(format!("🖼 {} — loading…", att.name))
.size(12)
.color(color_subtext)
.into()
}
None => row![
text(format!(
"🖼 {} ({})",
att.name,
crate::files::human_size(att.size)
))
.size(12)
.color(color_subtext)
.into(),
.color(color_text),
button(text("Load image").size(12))
.on_press_maybe(key.map(AppMessage::LoadImage))
.style(b_style(color_blue, color_lavender, color_crust, 6.0,))
.padding(6),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center)
.into(),
}
} else if crate::files::looks_like_audio_name(&att.name)
&& !state.invalid_audio.contains(&att.id)
@@ -7167,10 +7417,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.size(12)
.color(color_text),
button(
text(if matches!(data, Some(AttachmentState::Ready(_))) {
"Save"
} else {
"Download"
text(match data {
Some(AttachmentState::Ready(_)) => "Save",
Some(AttachmentState::Loading) => "Downloading…",
_ => "Download",
})
.size(12)
)
@@ -7212,8 +7462,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.spacing(4)
.into()
} else {
let ready = matches!(data, Some(AttachmentState::Ready(_)));
let btn_label = if ready { "Save" } else { "Download" };
// A repeated click during Loading only re-records the
// save intent (the update arm dedups the fetch), so the
// button stays enabled with an honest label.
let btn_label = match data {
Some(AttachmentState::Ready(_)) => "Save",
Some(AttachmentState::Loading) => "Downloading…",
_ => "Download",
};
row![
text(format!(
"📎 {} ({})",
@@ -9018,17 +9274,35 @@ mod tests {
(SecretKey::generate().public(), id)
}
/// A `Ready` state around bytes (tests only).
fn ready(bytes: Vec<u8>) -> AttachmentState {
AttachmentState::Ready(std::sync::Arc::new(bytes))
}
/// A cache with byte budgets too large to bind, isolating count-cap tests.
fn count_only_cache(cap: usize) -> AttachmentCache {
AttachmentCache::new(cap, usize::MAX, usize::MAX)
}
/// A dummy preview handle carrying an explicit decoded weight (tests only).
fn preview(decoded_cost: usize) -> super::PreviewHandle {
super::PreviewHandle {
handle: iced::widget::image::Handle::from_rgba(1, 1, vec![0u8; 4]),
decoded_cost,
}
}
#[test]
fn attachment_cache_evicts_oldest_when_full() {
let mut cache = AttachmentCache::new(2);
let mut cache = count_only_cache(2);
let k1 = peer_key([1u8; 32]);
let k2 = peer_key([2u8; 32]);
let k3 = peer_key([3u8; 32]);
cache.insert(k1, AttachmentState::Ready(vec![1]), None);
cache.insert(k2, AttachmentState::Ready(vec![2]), None);
cache.insert(k1, ready(vec![1]), None);
cache.insert(k2, ready(vec![2]), None);
assert_eq!(cache.len(), 2);
// Inserting a third NEW key evicts the oldest (k1), not the newest.
cache.insert(k3, AttachmentState::Ready(vec![3]), None);
cache.insert(k3, ready(vec![3]), None);
assert_eq!(cache.len(), 2);
assert!(cache.get(&k1).is_none(), "oldest should be evicted");
assert!(cache.get(&k2).is_some());
@@ -9037,17 +9311,17 @@ mod tests {
#[test]
fn attachment_cache_replace_keeps_position_and_count() {
let mut cache = AttachmentCache::new(2);
let mut cache = count_only_cache(2);
let k1 = peer_key([1u8; 32]);
let k2 = peer_key([2u8; 32]);
cache.insert(k1, AttachmentState::Failed("pending".into()), None);
cache.insert(k2, AttachmentState::Ready(vec![2]), None);
cache.insert(k2, ready(vec![2]), None);
// Replacing k1 (Failed -> Ready) must NOT bump it to newest; it stays the
// eviction candidate, and the count is unchanged (no order leak).
cache.insert(k1, AttachmentState::Ready(vec![1]), None);
cache.insert(k1, ready(vec![1]), None);
assert_eq!(cache.len(), 2);
let k3 = peer_key([3u8; 32]);
cache.insert(k3, AttachmentState::Ready(vec![3]), None);
cache.insert(k3, ready(vec![3]), None);
assert!(cache.get(&k1).is_none(), "replaced entry kept its old age");
assert!(cache.get(&k2).is_some());
assert!(cache.get(&k3).is_some());
@@ -9057,26 +9331,26 @@ mod tests {
fn attachment_cache_same_id_distinct_authors_do_not_alias() {
// The F-12 core property: two peers sending the SAME attachment id keep
// separate bytes — one never overwrites or aliases the other.
let mut cache = AttachmentCache::new(8);
let mut cache = count_only_cache(8);
let shared_id = [7u8; 32];
let victim = peer_key(shared_id);
let attacker = peer_key(shared_id);
cache.insert(victim, AttachmentState::Ready(vec![1, 1, 1]), None);
cache.insert(attacker, AttachmentState::Ready(vec![9, 9, 9]), None);
cache.insert(victim, ready(vec![1, 1, 1]), None);
cache.insert(attacker, ready(vec![9, 9, 9]), None);
assert_eq!(cache.len(), 2);
assert!(matches!(cache.get(&victim), Some(AttachmentState::Ready(b)) if b == &[1, 1, 1]));
assert!(matches!(cache.get(&attacker), Some(AttachmentState::Ready(b)) if b == &[9, 9, 9]));
assert!(
matches!(cache.get(&victim), Some(AttachmentState::Ready(b)) if b.as_slice() == [1, 1, 1])
);
assert!(
matches!(cache.get(&attacker), Some(AttachmentState::Ready(b)) if b.as_slice() == [9, 9, 9])
);
}
#[test]
fn attachment_cache_is_ready_and_handle_and_clear() {
let mut cache = AttachmentCache::new(4);
let mut cache = count_only_cache(4);
let k = peer_key([5u8; 32]);
cache.insert(
k,
AttachmentState::Ready(vec![1]),
Some(iced::widget::image::Handle::from_bytes(vec![1])),
);
cache.insert(k, ready(vec![1]), Some(preview(4)));
assert!(cache.is_ready(&k));
assert!(cache.handle(&k).is_some());
let failed = peer_key([6u8; 32]);
@@ -9086,17 +9360,105 @@ mod tests {
cache.clear();
assert_eq!(cache.len(), 0);
assert!(cache.get(&k).is_none());
assert_eq!(cache.encoded_total, 0);
assert_eq!(cache.decoded_total, 0);
}
#[test]
fn attachment_cache_cap_zero_is_clamped_to_one() {
let mut cache = AttachmentCache::new(0);
let mut cache = count_only_cache(0);
let k = peer_key([1u8; 32]);
cache.insert(k, AttachmentState::Ready(vec![1]), None);
cache.insert(k, ready(vec![1]), None);
assert_eq!(cache.len(), 1);
assert!(cache.get(&k).is_some());
}
#[test]
fn attachment_cache_encoded_budget_evicts_oldest() {
// Budget of 100 encoded bytes; entries of 60 + 30 fit, adding 40 must
// evict the oldest (60) — not fail, not exceed the budget.
let mut cache = AttachmentCache::new(64, 100, usize::MAX);
let k1 = peer_key([1u8; 32]);
let k2 = peer_key([2u8; 32]);
let k3 = peer_key([3u8; 32]);
assert!(cache.insert(k1, ready(vec![0; 60]), None));
assert!(cache.insert(k2, ready(vec![0; 30]), None));
assert_eq!(cache.encoded_total, 90);
assert!(cache.insert(k3, ready(vec![0; 40]), None));
assert!(cache.get(&k1).is_none(), "oldest evicted for byte budget");
assert!(cache.get(&k2).is_some());
assert!(cache.get(&k3).is_some());
assert_eq!(cache.encoded_total, 70);
}
#[test]
fn attachment_cache_decoded_budget_evicts_oldest() {
let mut cache = AttachmentCache::new(64, usize::MAX, 100);
let k1 = peer_key([1u8; 32]);
let k2 = peer_key([2u8; 32]);
assert!(cache.insert(k1, ready(vec![1]), Some(preview(80))));
assert!(cache.insert(k2, ready(vec![2]), Some(preview(40))));
assert!(
cache.get(&k1).is_none(),
"oldest evicted for decoded budget"
);
assert!(cache.get(&k2).is_some());
assert_eq!(cache.decoded_total, 40);
}
#[test]
fn attachment_cache_replacement_subtracts_old_weight_first() {
// k1 holds 80 of a 100-byte budget. Replacing it with 90 must fit
// WITHOUT evicting k2 (10): the old 80 is subtracted before the check.
let mut cache = AttachmentCache::new(64, 100, usize::MAX);
let k1 = peer_key([1u8; 32]);
let k2 = peer_key([2u8; 32]);
assert!(cache.insert(k1, ready(vec![0; 80]), None));
assert!(cache.insert(k2, ready(vec![0; 10]), None));
assert!(cache.insert(k1, ready(vec![0; 90]), None));
assert!(cache.get(&k2).is_some(), "replacement must not over-evict");
assert_eq!(cache.encoded_total, 100);
// A non-Ready replacement releases the weight entirely.
cache.insert(k1, AttachmentState::Evicted, None);
assert_eq!(cache.encoded_total, 10);
}
#[test]
fn attachment_cache_overweight_entry_is_not_retained() {
let mut cache = AttachmentCache::new(64, 100, usize::MAX);
let k1 = peer_key([1u8; 32]);
let k2 = peer_key([2u8; 32]);
assert!(cache.insert(k2, ready(vec![0; 50]), None));
// Individually larger than the whole budget: rejected, nothing evicted.
assert!(!cache.insert(k1, ready(vec![0; 101]), None));
assert!(cache.get(&k1).is_none());
assert!(cache.get(&k2).is_some(), "others untouched by the reject");
assert_eq!(cache.encoded_total, 50);
// Rejecting a REPLACEMENT drops the stale entry too (its old bytes
// must not linger under a key the caller will mark Evicted).
assert!(!cache.insert(k2, ready(vec![0; 200]), None));
assert!(cache.get(&k2).is_none());
assert_eq!(cache.encoded_total, 0);
// The caller's follow-up Evicted marker is retained (zero weight).
assert!(cache.insert(k1, AttachmentState::Evicted, None));
assert!(matches!(cache.get(&k1), Some(AttachmentState::Evicted)));
}
#[test]
fn attachment_cache_loading_states_are_weightless_and_tracked() {
let mut cache = AttachmentCache::new(64, 100, 100);
let k = peer_key([1u8; 32]);
assert!(cache.insert(k, AttachmentState::Loading, None));
assert!(cache.is_loading(&k));
assert!(!cache.is_ready(&k));
assert_eq!(cache.encoded_total, 0);
// Ready replaces Loading, weights counted; not loading any more.
assert!(cache.insert(k, ready(vec![0; 10]), None));
assert!(!cache.is_loading(&k));
assert!(cache.is_ready(&k));
assert_eq!(cache.encoded_total, 10);
}
#[test]
fn attachment_default_name_matches_full_key_not_bare_id() {
// Two chat lines carry the SAME attachment id but come from different
@@ -9166,11 +9528,9 @@ mod tests {
});
state.chat_input = "draft".to_string();
let att_key = (peer, attachment_id);
state.attachments.insert(
att_key,
AttachmentState::Ready(vec![1]),
Some(iced::widget::image::Handle::from_bytes(vec![1])),
);
state
.attachments
.insert(att_key, ready(vec![1]), Some(preview(4)));
state.pending_saves.insert(att_key);
state.pending_plays.insert(attachment_id);
state.invalid_audio.insert(attachment_id);