From 66dd5eafe2e86f47aae6becfd26f6ba8f3234873 Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Wed, 21 Oct 2026 17:15:43 -0700 Subject: [PATCH 1/4] Bound the line layout cache by size, not only by count Every edit to a long line left another shaped copy in the cache, and copies were only dropped past 3000 entries. A 330 KB line cost ~100 MB a copy, so typing in it reached 53 GB. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/stet-app/src/view.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/stet-app/src/view.rs b/crates/stet-app/src/view.rs index f0cb16d..db9d7e3 100644 --- a/crates/stet-app/src/view.rs +++ b/crates/stet-app/src/view.rs @@ -1092,7 +1092,9 @@ impl View { self.context_menu(ed, &m, &mut frame.over); self.retouching(ed, m.ui, &mut frame.over); self.sheet(ed, &m, &mut frame.over); - if self.layouts.len() > 3000 { + // Bounded by text as well as count: a shaped line costs hundreds of + // bytes a character, and every edit to a long line leaves a copy. + if self.layouts.len() > 3000 || self.layouts.values().map(|layout| layout.text.len()).sum::() > 1 << 20 { let keep = self.frame - 1; self.layouts.retain(|_, layout| layout.last_used >= keep); } From 77f1c29df02130249cb6e6159d2cfeacba16727d Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Wed, 21 Oct 2026 18:55:14 -0700 Subject: [PATCH 2/4] Evict huge shaped lines as they are laid out, not only when a frame is drawn Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/stet-app/src/view.rs | 62 ++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/crates/stet-app/src/view.rs b/crates/stet-app/src/view.rs index db9d7e3..e964a0e 100644 --- a/crates/stet-app/src/view.rs +++ b/crates/stet-app/src/view.rs @@ -147,9 +147,31 @@ struct Metrics { row: f32, } +/// A line this long is tracked on its own: it costs tens of megabytes shaped. +const HUGE: usize = 16 << 10; +/// Most text the layout cache keeps between frames, in bytes. +const BUDGET: usize = 1 << 20; + +/// Notes `key` as the most recently used huge line, and returns the keys of +/// the oldest ones to drop to get back under `BUDGET`. Two always stay: the +/// line being edited and one a pane or a frame still draws. +fn touch_huge(huge: &mut Vec<(u64, usize)>, key: u64, len: usize) -> Vec { + huge.retain(|(k, _)| *k != key); + huge.push((key, len)); + let mut total: usize = huge.iter().map(|(_, len)| len).sum(); + let mut dropped = 0; + while huge.len() - dropped > 2 && total > BUDGET { + total -= huge[dropped].1; + dropped += 1; + } + huge.drain(..dropped).map(|(k, _)| k).collect() +} + pub struct View { pub fonts: Fonts, pub layouts: HashMap, + /// `(key, text bytes)` of the huge lines among `layouts`, least recently used first. + huge: Vec<(u64, usize)>, pub images: Images, pub width: f32, pub height: f32, @@ -222,6 +244,7 @@ impl View { View { fonts, layouts: HashMap::new(), + huge: Vec::new(), images, width: 1000.0, height: 700.0, @@ -404,6 +427,7 @@ impl View { /// Drops shaped lines, after anything that changes how text looks. pub fn invalidate(&mut self) { self.layouts.clear(); + self.huge.clear(); self.advance = None; self.mono_advance = None; } @@ -646,9 +670,21 @@ impl View { layout }); layout.last_used = self.frame; + let len = layout.text.len(); + if len >= HUGE { + self.note_huge(key, len); + } key } + /// Marks a huge line's layout as just used, and drops the oldest ones past + /// the budget. Edits arrive faster than frames, so this can't wait for one. + fn note_huge(&mut self, key: u64, len: usize) { + for old in touch_huge(&mut self.huge, key, len) { + self.layouts.remove(&old); + } + } + /// A single-row label, cached like any other line. fn label(&mut self, ed: &Editor, text: &str, rgb: Rgb, size: f32, face: Face, bold: bool) -> u64 { let font = size.round(); @@ -1094,9 +1130,11 @@ impl View { self.sheet(ed, &m, &mut frame.over); // Bounded by text as well as count: a shaped line costs hundreds of // bytes a character, and every edit to a long line leaves a copy. - if self.layouts.len() > 3000 || self.layouts.values().map(|layout| layout.text.len()).sum::() > 1 << 20 { + if self.layouts.len() > 3000 || self.layouts.values().map(|layout| layout.text.len()).sum::() > BUDGET { let keep = self.frame - 1; self.layouts.retain(|_, layout| layout.last_used >= keep); + let layouts = &self.layouts; + self.huge.retain(|(k, _)| layouts.contains_key(k)); } frame } @@ -2486,3 +2524,25 @@ impl View { ); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn huge_lines_are_dropped_oldest_first_past_the_budget() { + let mut huge = Vec::new(); + let big = BUDGET / 3 + 1; + assert!(touch_huge(&mut huge, 1, big).is_empty()); + assert!(touch_huge(&mut huge, 2, big).is_empty()); + // Touching one again only moves it up. + assert!(touch_huge(&mut huge, 1, big).is_empty()); + // The third tips it over: the least recently used goes. + assert_eq!(touch_huge(&mut huge, 3, big), vec![2]); + // A flood of keys, as when typing outruns frames, leaves two. + for key in 10..100 { + touch_huge(&mut huge, key, 20 * BUDGET); + } + assert_eq!(huge.iter().map(|(k, _)| *k).collect::>(), vec![98, 99]); + } +} From 6142c69932348ffe428af901712a04f586fc35d7 Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Wed, 21 Oct 2026 18:58:21 -0700 Subject: [PATCH 3/4] Keep the lines a frame is drawing when evicting huge ones mid-frame Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/stet-app/src/view.rs | 73 ++++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/crates/stet-app/src/view.rs b/crates/stet-app/src/view.rs index e964a0e..3812fb3 100644 --- a/crates/stet-app/src/view.rs +++ b/crates/stet-app/src/view.rs @@ -152,26 +152,36 @@ const HUGE: usize = 16 << 10; /// Most text the layout cache keeps between frames, in bytes. const BUDGET: usize = 1 << 20; -/// Notes `key` as the most recently used huge line, and returns the keys of -/// the oldest ones to drop to get back under `BUDGET`. Two always stay: the -/// line being edited and one a pane or a frame still draws. -fn touch_huge(huge: &mut Vec<(u64, usize)>, key: u64, len: usize) -> Vec { - huge.retain(|(k, _)| *k != key); - huge.push((key, len)); - let mut total: usize = huge.iter().map(|(_, len)| len).sum(); - let mut dropped = 0; - while huge.len() - dropped > 2 && total > BUDGET { - total -= huge[dropped].1; - dropped += 1; +/// Notes `key` as the most recently used huge line, used in frame `used`, and +/// returns the keys of the oldest ones to drop to get back under `BUDGET`. +/// Two always stay: the line being edited and one a pane still draws. None +/// used in frame `floor` or later goes, as the frame being drawn holds them. +fn touch_huge(huge: &mut Vec<(u64, usize, u64)>, key: u64, len: usize, used: u64, floor: u64) -> Vec { + huge.retain(|(k, ..)| *k != key); + huge.push((key, len, used)); + let mut total: usize = huge.iter().map(|(_, len, _)| len).sum(); + let mut dropped = Vec::new(); + let mut at = 0; + while huge.len() > 2 && total > BUDGET && at < huge.len() { + if huge[at].2 < floor { + let (k, len, _) = huge.remove(at); + total -= len; + dropped.push(k); + } else { + at += 1; + } } - huge.drain(..dropped).map(|(k, _)| k).collect() + dropped } pub struct View { pub fonts: Fonts, pub layouts: HashMap, - /// `(key, text bytes)` of the huge lines among `layouts`, least recently used first. - huge: Vec<(u64, usize)>, + /// `(key, text bytes, frame used)` of the huge lines among `layouts`, least + /// recently used first. + huge: Vec<(u64, usize, u64)>, + /// Whether a frame is being drawn, and so holds the keys of its lines. + drawing: bool, pub images: Images, pub width: f32, pub height: f32, @@ -245,6 +255,7 @@ impl View { fonts, layouts: HashMap::new(), huge: Vec::new(), + drawing: false, images, width: 1000.0, height: 700.0, @@ -680,7 +691,8 @@ impl View { /// Marks a huge line's layout as just used, and drops the oldest ones past /// the budget. Edits arrive faster than frames, so this can't wait for one. fn note_huge(&mut self, key: u64, len: usize) { - for old in touch_huge(&mut self.huge, key, len) { + let floor = if self.drawing { self.frame } else { u64::MAX }; + for old in touch_huge(&mut self.huge, key, len, self.frame, floor) { self.layouts.remove(&old); } } @@ -1063,6 +1075,7 @@ impl View { pub fn frame(&mut self, ed: &mut Editor) -> Frame { ed.refresh(); self.frame += 1; + self.drawing = true; self.now = Instant::now(); self.targets.clear(); self.videos.clear(); @@ -1134,8 +1147,9 @@ impl View { let keep = self.frame - 1; self.layouts.retain(|_, layout| layout.last_used >= keep); let layouts = &self.layouts; - self.huge.retain(|(k, _)| layouts.contains_key(k)); + self.huge.retain(|(k, ..)| layouts.contains_key(k)); } + self.drawing = false; frame } @@ -2533,16 +2547,31 @@ mod tests { fn huge_lines_are_dropped_oldest_first_past_the_budget() { let mut huge = Vec::new(); let big = BUDGET / 3 + 1; - assert!(touch_huge(&mut huge, 1, big).is_empty()); - assert!(touch_huge(&mut huge, 2, big).is_empty()); + let free = u64::MAX; + assert!(touch_huge(&mut huge, 1, big, 0, free).is_empty()); + assert!(touch_huge(&mut huge, 2, big, 0, free).is_empty()); // Touching one again only moves it up. - assert!(touch_huge(&mut huge, 1, big).is_empty()); + assert!(touch_huge(&mut huge, 1, big, 0, free).is_empty()); // The third tips it over: the least recently used goes. - assert_eq!(touch_huge(&mut huge, 3, big), vec![2]); + assert_eq!(touch_huge(&mut huge, 3, big, 0, free), vec![2]); // A flood of keys, as when typing outruns frames, leaves two. for key in 10..100 { - touch_huge(&mut huge, key, 20 * BUDGET); + touch_huge(&mut huge, key, 20 * BUDGET, 0, free); } - assert_eq!(huge.iter().map(|(k, _)| *k).collect::>(), vec![98, 99]); + let keys = |huge: &Vec<(u64, usize, u64)>| huge.iter().map(|(k, ..)| *k).collect::>(); + assert_eq!(keys(&huge), vec![98, 99]); + } + + #[test] + fn huge_lines_the_drawn_frame_holds_stay() { + let mut huge = Vec::new(); + let big = BUDGET / 2; + // Stale copies from typing between frames, then frame 5 draws three. + touch_huge(&mut huge, 1, big, 4, u64::MAX); + for key in 10..13 { + touch_huge(&mut huge, key, big, 5, 5); + } + let keys: Vec = huge.iter().map(|(k, ..)| *k).collect(); + assert_eq!(keys, vec![10, 11, 12]); } } From ceba93fe25aa762ce2fa91d2bc579c94435b2d4d Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Wed, 21 Oct 2026 19:06:06 -0700 Subject: [PATCH 4/4] Free the textures of pictures not drawn for a minute, and cap decode sizes Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/stet-app/src/app.rs | 2 + crates/stet-app/src/gpu.rs | 7 ++ crates/stet-app/src/images.rs | 179 +++++++++++++++++++++++++++++++++- crates/stet-app/src/view.rs | 1 + 4 files changed, 184 insertions(+), 5 deletions(-) diff --git a/crates/stet-app/src/app.rs b/crates/stet-app/src/app.rs index 38b4f54..8dfebd0 100644 --- a/crates/stet-app/src/app.rs +++ b/crates/stet-app/src/app.rs @@ -435,6 +435,8 @@ impl Running { for image in self.session.view.uploads() { self.gpu.upload_image(image.id, image.width, image.height, &image.rgba); } + let stale = self.session.view.images.evict(Instant::now()); + self.gpu.free_images(&stale); #[cfg(feature = "substack")] self.session.publish.poll(&mut self.session.editor); if !self.drawn { diff --git a/crates/stet-app/src/gpu.rs b/crates/stet-app/src/gpu.rs index 19e727f..2b8dfab 100644 --- a/crates/stet-app/src/gpu.rs +++ b/crates/stet-app/src/gpu.rs @@ -241,6 +241,13 @@ impl Gpu { self.textures.insert(id, bind); } + /// Drops the textures of pictures no longer kept. + pub fn free_images(&mut self, ids: &[u64]) { + for id in ids { + self.textures.remove(id); + } + } + pub fn render( &mut self, target: &wgpu::TextureView, diff --git a/crates/stet-app/src/images.rs b/crates/stet-app/src/images.rs index 91020af..f51341e 100644 --- a/crates/stet-app/src/images.rs +++ b/crates/stet-app/src/images.rs @@ -6,11 +6,20 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender, channel}; +use std::time::{Duration, Instant}; /// Larger images are downscaled before upload. const MAX_SIDE: u32 = 4096; +/// Larger sources are not decoded at all. +const MAX_DECODE: u32 = 16384; /// Downloads stop here. const MAX_BYTES: u64 = 32 << 20; +/// A texture not drawn for this long is freed. +const KEEP: Duration = Duration::from_secs(60); +/// Over this many bytes of textures, the longest unseen go first... +const BUDGET: u64 = 512 << 20; +/// ...but nothing drawn within this long, so scrolling never reloads. +const RECENT: Duration = Duration::from_secs(2); /// The longer side of a video's frame. const POSTER_SIDE: u32 = 1280; @@ -24,7 +33,25 @@ pub struct Decoded { enum State { Loading, Failed, - Ready { id: u64, width: u32, height: u32 }, + /// Decoded and uploaded; `used` is when it was last drawn. + Ready { + id: u64, + width: u32, + height: u32, + used: Instant, + }, + /// Its texture was freed. The size stays, so the layout holds still. + Evicted { + id: u64, + width: u32, + height: u32, + }, + /// Drawn again after eviction: decoding anew, under a new id. + Reloading { + id: u64, + width: u32, + height: u32, + }, } /// Where an image comes from. @@ -180,7 +207,16 @@ fn load(source: &Source, cache: Option<&Path>) -> Option> { } fn decode(bytes: &[u8], id: u64) -> Option { - let image = image::load_from_memory(bytes).ok()?; + // Absurd sizes (a tiny file that inflates to gigabytes) are refused. + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_DECODE); + limits.max_image_height = Some(MAX_DECODE); + limits.max_alloc = Some(1 << 30); + reader.limits(limits); + let image = reader.decode().ok()?; let image = if image.width().max(image.height()) > MAX_SIDE { image.resize(MAX_SIDE, MAX_SIDE, image::imageops::FilterType::Triangle) } else { @@ -306,15 +342,28 @@ impl Images { } /// `(id, width, height)` once decoded; starts loading on first request. + /// Asking does not keep a texture alive, drawing it (`touch`) does. + /// After eviction the id is dead: it draws nothing until `touch` has + /// brought the picture back, but its size is still right. pub fn get(&mut self, source: &Source) -> Option<(u64, u32, u32)> { match self.states.get(source) { - Some(State::Ready { id, width, height }) => return Some((*id, *width, *height)), + Some( + State::Ready { id, width, height, .. } + | State::Evicted { id, width, height } + | State::Reloading { id, width, height }, + ) => return Some((*id, *width, *height)), Some(_) => return None, None => {} } + self.states.insert(source.clone(), State::Loading); + self.start(source); + None + } + + /// Decodes `source` under a fresh id, off the UI thread. + fn start(&mut self, source: &Source) { let id = self.next_id; self.next_id += 1; - self.states.insert(source.clone(), State::Loading); let (sender, wake, owned, cache) = ( self.sender.clone(), self.wake.clone(), @@ -335,7 +384,66 @@ impl Images { } else { std::thread::spawn(work); } - None + } + + /// `source` was drawn at `now`: keeps its texture, or brings it back. + pub fn touch(&mut self, source: &Source, now: Instant) { + match self.states.get_mut(source) { + Some(State::Ready { used, .. }) => *used = now, + Some(&mut State::Evicted { id, width, height }) => { + self.states + .insert(source.clone(), State::Reloading { id, width, height }); + self.start(source); + } + _ => {} + } + } + + /// Frees pictures not drawn for a minute, then the longest unseen + /// while textures exceed the budget. Returns the ids to free. + pub fn evict(&mut self, now: Instant) -> Vec { + let mut freed = Vec::new(); + let mut bytes = 0; + let mut resident = Vec::new(); + for (source, state) in &self.states { + if let State::Ready { + width, height, used, .. + } = state + { + bytes += *width as u64 * *height as u64 * 4; + let idle = now.saturating_duration_since(*used); + if idle >= KEEP { + freed.push(source.clone()); + } else if idle >= RECENT { + resident.push((*used, source.clone())); + } + } + } + for source in &freed { + if let Some(State::Ready { width, height, .. }) = self.states.get(source) { + bytes -= *width as u64 * *height as u64 * 4; + } + } + resident.sort_by_key(|(used, _)| *used); + for (_, source) in resident { + if bytes <= BUDGET { + break; + } + if let Some(State::Ready { width, height, .. }) = self.states.get(&source) { + bytes -= *width as u64 * *height as u64 * 4; + } + freed.push(source); + } + freed + .into_iter() + .filter_map(|source| match self.states.get(&source) { + Some(&State::Ready { id, width, height, .. }) => { + self.states.insert(source, State::Evicted { id, width, height }); + Some(id) + } + _ => None, + }) + .collect() } /// The file behind a picture, read now. @@ -352,6 +460,7 @@ impl Images { id: image.id, width: image.width, height: image.height, + used: Instant::now(), }, None => State::Failed, }; @@ -418,6 +527,66 @@ mod tests { assert!(super::poster(&Source::File("/no/such/clip.mp4".into()), 4).is_none()); } + fn ready(images: &mut Images, name: &str, id: u64, side: u32, used: Instant) -> Source { + let source = Source::File(name.into()); + images.states.insert( + source.clone(), + State::Ready { + id, + width: side, + height: side, + used, + }, + ); + source + } + + #[test] + fn pictures_unseen_for_a_while_are_evicted_and_return() { + let mut images = Images::new(Arc::new(|| {}), None); + images.blocking = true; + let start = Instant::now(); + let old = ready(&mut images, "/no/old.png", 1, 100, start); + let new = ready(&mut images, "/no/new.png", 2, 100, start + KEEP); + assert_eq!(images.evict(start + KEEP), vec![1]); + // Nothing twice, and the size stays so the layout holds still. + assert!(images.evict(start + KEEP).is_empty()); + assert_eq!(images.get(&old), Some((1, 100, 100))); + // Asking again does not bring it back, drawing does: under a new id. + assert!(images.poll().is_empty()); + images.touch(&new, start + KEEP); + images.touch(&old, start + KEEP); + // The file is not there: the reload fails, once, and does not loop. + assert!(images.poll().is_empty()); + assert_eq!(images.get(&old), None); + images.touch(&old, start + KEEP); + assert!(images.poll().is_empty()); + assert!(matches!(images.states[&new], State::Ready { id: 2, .. })); + } + + #[test] + fn a_budget_frees_the_longest_unseen_but_never_the_recent() { + let mut images = Images::new(Arc::new(|| {}), None); + let start = Instant::now(); + // 4096² is 64 MiB: ten of them is over the budget. + for at in 0..10u64 { + ready( + &mut images, + &format!("/no/{at}.png"), + at + 1, + 4096, + start + Duration::from_secs(at), + ); + } + let now = start + Duration::from_secs(9) + RECENT; + let mut freed = images.evict(now); + freed.sort(); + // Two come off; the one drawn last (id 10) and its neighbour are safe. + assert_eq!(freed, vec![1, 2]); + let all = start + Duration::from_secs(9); + assert!(images.evict(all).is_empty()); + } + #[test] fn data_uris_decode() { assert_eq!(base64_decode("aGVsbG8=").as_deref(), Some(&b"hello"[..])); diff --git a/crates/stet-app/src/view.rs b/crates/stet-app/src/view.rs index db9d7e3..c4c3f14 100644 --- a/crates/stet-app/src/view.rs +++ b/crates/stet-app/src/view.rs @@ -1424,6 +1424,7 @@ impl View { if y < m.bottom { let rect = [m.left, y + gap * 0.5, width, height]; layer.images.push((id, rect)); + self.images.touch(&source, Instant::now()); let (top, bottom) = (rect[1].max(m.top), (rect[1] + height).min(m.bottom)); let seen = [m.left, top, width, bottom - top]; match source.video() {