From 15629ec5a988629ab657628eed11408f7848f5df Mon Sep 17 00:00:00 2001 From: enki Date: Tue, 16 Jun 2026 10:27:46 -0700 Subject: [PATCH] Cap per-page texture caches to stop unbounded RSS growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every page held an uncapped HashMap that only ever grew — gdk textures keep their decoded RGBA resident, so every image/avatar/cover scrolled past stayed in memory for the session (the multi-GB RSS climb). Add a bounded TextureCache (insertion-order eviction past 200 textures; the core disk/byte cache still holds the source for cheap re-decode) and use it on all seven pages. Co-Authored-By: Claude Opus 4.8 --- crates/sunstone-gtk/src/components/media.rs | 60 +++++++++++++++++++ crates/sunstone-gtk/src/pages/bookmarks.rs | 4 +- crates/sunstone-gtk/src/pages/feed.rs | 4 +- crates/sunstone-gtk/src/pages/groups.rs | 6 +- crates/sunstone-gtk/src/pages/messages.rs | 6 +- .../sunstone-gtk/src/pages/notifications.rs | 6 +- crates/sunstone-gtk/src/pages/streams.rs | 6 +- crates/sunstone-gtk/src/pages/videos.rs | 5 +- 8 files changed, 78 insertions(+), 19 deletions(-) diff --git a/crates/sunstone-gtk/src/components/media.rs b/crates/sunstone-gtk/src/components/media.rs index 72c01eb..24310a0 100644 --- a/crates/sunstone-gtk/src/components/media.rs +++ b/crates/sunstone-gtk/src/components/media.rs @@ -1,8 +1,68 @@ //! Texture helpers: decode-at-size so a 4000px photo never becomes a //! 4000px texture for a 34px avatar. +use std::collections::{HashMap, VecDeque}; + use relm4::gtk::{gdk, gdk_pixbuf, prelude::*}; +/// Bounded texture cache. gdk textures keep their decoded RGBA in memory, so an +/// uncapped per-page `HashMap` pinned every image ever +/// scrolled past — the cause of the multi-GB RSS climb. This evicts in +/// insertion order past `cap` (the disk/byte cache in core still holds the +/// source, so re-display just re-decodes, no network). Capacity is in number of +/// textures; with decode-at-size that bounds the footprint. +#[derive(Debug)] +pub struct TextureCache { + map: HashMap, + order: VecDeque, + cap: usize, +} + +impl TextureCache { + pub fn new(cap: usize) -> Self { + Self { + map: HashMap::new(), + order: VecDeque::new(), + cap: cap.max(1), + } + } + + pub fn get(&self, url: &str) -> Option<&gdk::Texture> { + self.map.get(url) + } + + pub fn contains_key(&self, url: &str) -> bool { + self.map.contains_key(url) + } + + pub fn insert(&mut self, url: String, texture: gdk::Texture) { + if self.map.insert(url.clone(), texture).is_none() { + self.order.push_back(url); + } + while self.map.len() > self.cap { + let Some(old) = self.order.pop_front() else { break }; + self.map.remove(&old); + } + } + + pub fn len(&self) -> usize { + self.map.len() + } + + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + + pub fn clear(&mut self) { + self.map.clear(); + self.order.clear(); + } + + pub fn values(&self) -> impl Iterator { + self.map.values() + } +} + /// Avatars and other small chrome. pub const AVATAR_PX: i32 = 128; /// Inline note images, stream/video thumbnails. diff --git a/crates/sunstone-gtk/src/pages/bookmarks.rs b/crates/sunstone-gtk/src/pages/bookmarks.rs index 767ccd2..78832da 100644 --- a/crates/sunstone-gtk/src/pages/bookmarks.rs +++ b/crates/sunstone-gtk/src/pages/bookmarks.rs @@ -75,7 +75,7 @@ pub struct BookmarksWidgets { refresh_btn: gtk::Button, avatar_slots: HashMap>, picture_slots: HashMap>, - textures: HashMap, + textures: crate::components::media::TextureCache, } fn ref_label(item: &BookmarkRef) -> String { @@ -589,7 +589,7 @@ impl Component for BookmarksPage { refresh_btn, avatar_slots: HashMap::new(), picture_slots: HashMap::new(), - textures: HashMap::new(), + textures: crate::components::media::TextureCache::new(200), }; // Data load is triggered lazily by main_view on first view. ComponentParts { model, widgets } diff --git a/crates/sunstone-gtk/src/pages/feed.rs b/crates/sunstone-gtk/src/pages/feed.rs index 60aaca9..15ad8f8 100644 --- a/crates/sunstone-gtk/src/pages/feed.rs +++ b/crates/sunstone-gtk/src/pages/feed.rs @@ -275,7 +275,7 @@ pub struct FeedWidgets { poll_slots: HashMap>, avatar_slots: HashMap>, picture_slots: HashMap>, - textures: HashMap, + textures: crate::components::media::TextureCache, } /// Hard caps on the feed list. The ListBox isn't virtualised, so every row @@ -2148,7 +2148,7 @@ impl Component for FeedPage { poll_slots: HashMap::new(), avatar_slots: HashMap::new(), picture_slots: HashMap::new(), - textures: HashMap::new(), + textures: crate::components::media::TextureCache::new(200), }; model.rebuild_sources(&mut widgets); { diff --git a/crates/sunstone-gtk/src/pages/groups.rs b/crates/sunstone-gtk/src/pages/groups.rs index 8df18eb..6c20a70 100644 --- a/crates/sunstone-gtk/src/pages/groups.rs +++ b/crates/sunstone-gtk/src/pages/groups.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use relm4::adw; use relm4::adw::prelude::*; -use relm4::gtk::{gdk, glib}; +use relm4::gtk::glib; use relm4::prelude::*; use sunstone_core::groups::{Group, GroupRef}; use sunstone_core::nostr_sdk::prelude::*; @@ -86,7 +86,7 @@ pub struct GroupsWidgets { chat_entry: gtk::Entry, leave_btn: gtk::Button, avatar_slots: HashMap>, - textures: HashMap, + textures: crate::components::media::TextureCache, } fn chat_row(message: &ChatMessage) -> gtk::ListBoxRow { @@ -523,7 +523,7 @@ impl Component for GroupsPage { chat_entry, leave_btn, avatar_slots: HashMap::new(), - textures: HashMap::new(), + textures: crate::components::media::TextureCache::new(200), }; // Live chat pump. diff --git a/crates/sunstone-gtk/src/pages/messages.rs b/crates/sunstone-gtk/src/pages/messages.rs index a0336b5..e3d03d1 100644 --- a/crates/sunstone-gtk/src/pages/messages.rs +++ b/crates/sunstone-gtk/src/pages/messages.rs @@ -7,7 +7,7 @@ use std::time::Duration; use relm4::adw; use relm4::adw::prelude::*; -use relm4::gtk::{gdk, glib}; +use relm4::gtk::glib; use relm4::prelude::*; use sunstone_core::dms::{Conversation, DirectMessage}; use sunstone_core::nostr_sdk::prelude::*; @@ -59,7 +59,7 @@ pub struct MessagesWidgets { chat_entry: gtk::Entry, error_label: gtk::Label, avatar_slots: HashMap>, - textures: HashMap, + textures: crate::components::media::TextureCache, } fn message_time(at: Timestamp) -> String { @@ -451,7 +451,7 @@ impl Component for MessagesPage { chat_entry, error_label, avatar_slots: HashMap::new(), - textures: HashMap::new(), + textures: crate::components::media::TextureCache::new(200), }; // Long-lived gift wrap subscription + notification pump. diff --git a/crates/sunstone-gtk/src/pages/notifications.rs b/crates/sunstone-gtk/src/pages/notifications.rs index 66b25b5..163339a 100644 --- a/crates/sunstone-gtk/src/pages/notifications.rs +++ b/crates/sunstone-gtk/src/pages/notifications.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use relm4::adw; use relm4::adw::prelude::*; -use relm4::gtk::{gdk, glib}; +use relm4::gtk::glib; use relm4::prelude::*; use sunstone_core::notifications::{Notification, NotificationKind}; use sunstone_core::nostr_sdk::prelude::*; @@ -76,7 +76,7 @@ pub struct NotificationsWidgets { new_pill: gtk::Label, mark_read_btn: gtk::Button, avatar_slots: HashMap>, - textures: HashMap, + textures: crate::components::media::TextureCache, } /// Badge icon + colour class per kind. Mention is special-cased to an "@" @@ -730,7 +730,7 @@ impl Component for NotificationsPage { new_pill, mark_read_btn, avatar_slots: HashMap::new(), - textures: HashMap::new(), + textures: crate::components::media::TextureCache::new(200), }; // Live subscription + notification pump. diff --git a/crates/sunstone-gtk/src/pages/streams.rs b/crates/sunstone-gtk/src/pages/streams.rs index 539beaf..e32518a 100644 --- a/crates/sunstone-gtk/src/pages/streams.rs +++ b/crates/sunstone-gtk/src/pages/streams.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use relm4::adw; use relm4::adw::prelude::*; -use relm4::gtk::{gdk, glib}; +use relm4::gtk::glib; use relm4::prelude::*; use sunstone_core::nostr_sdk::prelude::*; use sunstone_core::streams::{stream_matches, ChatMessage, Stream, StreamStatus}; @@ -95,7 +95,7 @@ pub struct StreamsWidgets { chat_scroll: Option, picture_slots: HashMap>, avatar_slots: HashMap>, - textures: HashMap, + textures: crate::components::media::TextureCache, /// Zap buttons per stream index (grid pill + player CTA), so the /// sending spinner hits the right button(s). zap_slots: HashMap>, @@ -753,7 +753,7 @@ impl Component for StreamsPage { chat_scroll: None, picture_slots: HashMap::new(), avatar_slots: HashMap::new(), - textures: HashMap::new(), + textures: crate::components::media::TextureCache::new(200), zap_slots: HashMap::new(), zap_feedback: crate::components::zap::ZapFeedback::new(), }; diff --git a/crates/sunstone-gtk/src/pages/videos.rs b/crates/sunstone-gtk/src/pages/videos.rs index 9279c84..f0e884d 100644 --- a/crates/sunstone-gtk/src/pages/videos.rs +++ b/crates/sunstone-gtk/src/pages/videos.rs @@ -5,7 +5,6 @@ use std::sync::Arc; use relm4::adw; use relm4::adw::prelude::*; -use relm4::gtk::gdk; use relm4::prelude::*; use sunstone_core::video::Video; use sunstone_core::Core; @@ -65,7 +64,7 @@ pub struct VideosWidgets { load_more: gtk::Button, picture_slots: HashMap>, avatar_slots: HashMap>, - textures: HashMap, + textures: crate::components::media::TextureCache, /// Zap buttons per video index, for sending/result feedback. zap_slots: HashMap>, zap_feedback: crate::components::zap::ZapFeedback, @@ -432,7 +431,7 @@ impl Component for VideosPage { load_more, picture_slots: HashMap::new(), avatar_slots: HashMap::new(), - textures: HashMap::new(), + textures: crate::components::media::TextureCache::new(200), zap_slots: HashMap::new(), zap_feedback: crate::components::zap::ZapFeedback::new(), };