chat: attachment cache, download, and transfer hardening (Phase 3)
CI / check (push) Successful in 2m33s
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:
+442
-82
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user