atagen

macltok

public
Star0
Watch0

Commit da8896938cf0

tarball

atagen <boss@atagen.co>2026-10-25 10:04 UTC

12 files changed+438-109
media: give every registration an owner and free it with its owner

The shared 2048 entry LRU could evict media for rows still on screen whenever context paging or the mentions inbox registered enough, and context media were never freed at all. Each MediaScope now owns what it registers, an entry lives until every owning scope releases it, and only bounded scopes trim, and only their own least recently used. Event contexts become sessions with an id, closed explicitly, by leaving the room or by opening a 33rd, and EngineConfig::max_media_sources becomes max_context_media to say what it now bounds.
Modified file: src/engine/context.rs+165 additions−17 deletions
@@ -1,43 +1,190 @@
1-use std::{collections::HashMap, sync::Arc};
1+use std::{
2+ collections::{HashMap, VecDeque},
3+ sync::Arc,
4+};
25
36 use futures_util::future::{join_all, try_join};
47 use matrix_sdk::{
5- Room, deserialized_responses::TimelineEvent as SdkTimelineEvent, room::MessagesOptions,
8+ Client, Room, RoomState, deserialized_responses::TimelineEvent as SdkTimelineEvent,
9+ room::MessagesOptions,
610 };
7-use ruma::{OwnedEventId, OwnedUserId, UInt, push::Action};
11+use ruma::{OwnedEventId, OwnedRoomId, OwnedUserId, UInt, push::Action};
812 use serde_json::Value;
13+use tokio::sync::{Mutex, broadcast::error::RecvError};
914
1015 use crate::{
11- Error, ErrorKind, EventContext, EventContextCommand, EventContextPage, MediaId, Sender,
12- TimelineEvent, TimelineItem,
16+ Error, ErrorKind, EventContext, EventContextCommand, EventContextPage, EventContextSession,
17+ MediaId, Sender, TimelineEvent, TimelineItem,
1318 engine::MediaService,
1419 normalise::{
15- MediaRegistration, apply_profiles, normalise_context, normalise_observed, resolve_replies,
20+ MediaRegistration, MediaScope, apply_profiles, normalise_context, normalise_observed,
21+ resolve_replies,
1622 },
1723 };
1824
1925 const MAX_CONTEXT_LIMIT: u16 = 50;
26+/// a consumer that never closes its jump views still holds at most this many
27+const MAX_SESSIONS: usize = 32;
28+
29+/// open event contexts, oldest first, each owning the media its pages registered
30+#[derive(Default)]
31+pub(crate) struct Sessions {
32+ open: Mutex<VecDeque<Session>>,
33+}
34+
35+struct Session {
36+ id: String,
37+ room_id: OwnedRoomId,
38+ scope: MediaScope,
39+}
40+
41+impl Sessions {
42+ async fn open(
43+ &self,
44+ media: &MediaService,
45+ room_id: OwnedRoomId,
46+ registrations: Vec<MediaRegistration>,
47+ ) -> String {
48+ let scope = media.context_scope();
49+ let id = format!("context-{}", scope.nonce());
50+ let mut open = self.open.lock().await;
51+ media.register(&scope, registrations).await;
52+ open.push_back(Session {
53+ id: id.clone(),
54+ room_id,
55+ scope,
56+ });
57+ while open.len() > MAX_SESSIONS {
58+ if let Some(oldest) = open.pop_front() {
59+ media.release(&oldest.scope).await;
60+ }
61+ }
62+ id
63+ }
64+
65+ async fn room(&self, id: &str) -> Result<OwnedRoomId, Error> {
66+ let open = self.open.lock().await;
67+ let session = open.iter().find(|session| session.id == id);
68+ session
69+ .map(|session| session.room_id.clone())
70+ .ok_or_else(not_open)
71+ }
72+
73+ /// under the lock, so a page that lands after its close registers nothing
74+ async fn register(
75+ &self,
76+ media: &MediaService,
77+ id: &str,
78+ registrations: Vec<MediaRegistration>,
79+ ) -> Result<(), Error> {
80+ let open = self.open.lock().await;
81+ let session = open
82+ .iter()
83+ .find(|session| session.id == id)
84+ .ok_or_else(not_open)?;
85+ media.register(&session.scope, registrations).await;
86+ Ok(())
87+ }
88+
89+ async fn close(&self, media: &MediaService, id: &str) -> Result<(), Error> {
90+ let mut open = self.open.lock().await;
91+ let index = open
92+ .iter()
93+ .position(|session| session.id == id)
94+ .ok_or_else(not_open)?;
95+ if let Some(session) = open.remove(index) {
96+ media.release(&session.scope).await;
97+ }
98+ Ok(())
99+ }
100+
101+ async fn close_left(&self, media: &MediaService, client: &Client) {
102+ let mut open = self.open.lock().await;
103+ let mut kept = VecDeque::with_capacity(open.len());
104+ for session in open.drain(..) {
105+ let left = client
106+ .get_room(&session.room_id)
107+ .is_none_or(|room| matches!(room.state(), RoomState::Left | RoomState::Banned));
108+ if left {
109+ media.release(&session.scope).await;
110+ } else {
111+ kept.push_back(session);
112+ }
113+ }
114+ *open = kept;
115+ }
116+
117+ /// the media service is cleared beside this, so nothing is released one by one
118+ pub(crate) async fn clear(&self) {
119+ self.open.lock().await.clear();
120+ }
121+}
122+
123+fn not_open() -> Error {
124+ Error::new(
125+ ErrorKind::EventContextNotOpen,
126+ "event context is closed or was never opened",
127+ )
128+}
129+
130+/// leaving a room closes its event contexts
131+pub(super) async fn close_on_leave(
132+ client: Client,
133+ media: Arc<MediaService>,
134+ sessions: Arc<Sessions>,
135+) {
136+ let mut info = client.room_info_notable_update_receiver();
137+ loop {
138+ match info.recv().await {
139+ Ok(_) | Err(RecvError::Lagged(_)) => sessions.close_left(&media, &client).await,
140+ Err(RecvError::Closed) => return,
141+ }
142+ }
143+}
20144
21145 pub(crate) async fn execute(
22- room: Room,
23- current_user_id: Option<OwnedUserId>,
146+ client: Client,
24147 media: Arc<MediaService>,
148+ sessions: Arc<Sessions>,
25149 command: EventContextCommand,
26150 ) -> Result<crate::CommandOutput, Error> {
27- let (token, limit, backwards) = match command {
151+ let current_user_id = client.user_id().map(ToOwned::to_owned);
152+ let room = |room_id: &OwnedRoomId| client.get_room(room_id).ok_or_else(Error::room_unknown);
153+ let (context_id, token, limit, backwards) = match command {
28154 EventContextCommand::Fetch {
155+ room_id,
29156 event_id,
30157 before_limit,
31158 after_limit,
32- ..
33159 } => {
34- let (context, registrations, _) =
35- capture(room, current_user_id, event_id, before_limit, after_limit).await?;
36- media.register(registrations).await;
37- return Ok(crate::CommandOutput::EventContext(context));
160+ let (context, registrations, _) = capture(
161+ room(&room_id)?,
162+ current_user_id,
163+ event_id,
164+ before_limit,
165+ after_limit,
166+ )
167+ .await?;
168+ let context_id = sessions.open(&media, room_id, registrations).await;
169+ return Ok(crate::CommandOutput::EventContext(EventContextSession {
170+ context_id,
171+ context,
172+ }));
173+ }
174+ EventContextCommand::Close { context_id } => {
175+ sessions.close(&media, &context_id).await?;
176+ return Ok(crate::CommandOutput::Done);
38177 }
39- EventContextCommand::Before { token, limit, .. } => (token, limit, true),
40- EventContextCommand::After { token, limit, .. } => (token, limit, false),
178+ EventContextCommand::Before {
179+ context_id,
180+ token,
181+ limit,
182+ } => (context_id, token, limit, true),
183+ EventContextCommand::After {
184+ context_id,
185+ token,
186+ limit,
187+ } => (context_id, token, limit, false),
41188 };
42189 if limit == 0 {
43190 return Err(Error::new(
@@ -52,6 +199,7 @@ pub(crate) async fn execute(
52199 "context pagination token is empty",
53200 ));
54201 }
202+ let room = room(&sessions.room(&context_id).await?)?;
55203 let side = fetch_side(&room, Some(&token), limit, backwards).await?;
56204 let mut page = Assembly::new(
57205 &room,
@@ -62,7 +210,7 @@ pub(crate) async fn execute(
62210 )
63211 .await;
64212 let events = page.take(0..side.values.len());
65- media.register(page.media).await;
213+ sessions.register(&media, &context_id, page.media).await?;
66214 Ok(crate::CommandOutput::EventContextPage(EventContextPage {
67215 events,
68216 next_token: side.token,
Modified file: src/engine/media.rs+160 additions−39 deletions
@@ -56,27 +56,34 @@ pub(crate) struct MediaService {
5656 registry: Mutex<MediaRegistry>,
5757 flights: Mutex<HashMap<String, WeakShared<FlightFuture>>>,
5858 max_bytes: usize,
59+ context_limit: usize,
5960 url_previews: UrlPreviews,
6061 }
6162
6263 impl MediaService {
63- pub(crate) fn new(client: Client, max_bytes: usize, max_sources: usize) -> Self {
64+ pub(crate) fn new(client: Client, max_bytes: usize, context_limit: usize) -> Self {
6465 Self {
6566 url_previews: UrlPreviews::new(client.clone()),
6667 client,
67- registry: Mutex::new(MediaRegistry::new(max_sources)),
68+ registry: Mutex::new(MediaRegistry::default()),
6869 flights: Mutex::new(HashMap::new()),
6970 max_bytes,
71+ context_limit,
7072 }
7173 }
7274
75+ /// one event context session's media, bounded by `EngineConfig::max_context_media`
76+ pub(crate) fn context_scope(&self) -> MediaScope {
77+ MediaScope::bounded(self.context_limit)
78+ }
79+
7380 pub(crate) async fn url_preview(&self, url: &str) -> Result<Option<LinkPreview>, Error> {
7481 self.url_previews.preview(url).await
7582 }
7683
77- pub(crate) async fn register(&self, media: Vec<MediaRegistration>) {
84+ pub(crate) async fn register(&self, scope: &MediaScope, media: Vec<MediaRegistration>) {
7885 if !media.is_empty() {
79- self.registry.lock().await.insert_all(media);
86+ self.registry.lock().await.insert_all(scope, media);
8087 }
8188 }
8289
@@ -747,64 +754,178 @@ async fn check_reply_thread(
747754 }
748755 }
749756
750-/// least recently seen goes first. stale order slots are skipped by stamp rather than hunted down
757+/// every entry belongs to the scopes that registered it and lives until the last of them
758+/// lets go. there is no global cap, a bounded scope only ever trims itself
759+#[derive(Default)]
751760 struct MediaRegistry {
752- entries: HashMap<MediaId, (MediaRegistration, u64)>,
761+ entries: HashMap<MediaId, Entry>,
762+ scopes: HashMap<u64, ScopeEntries>,
763+}
764+
765+struct Entry {
766+ media: MediaRegistration,
767+ owners: Vec<u64>,
768+}
769+
770+/// least recently used first. stale order slots are skipped by stamp rather than hunted down
771+#[derive(Default)]
772+struct ScopeEntries {
773+ stamps: HashMap<MediaId, u64>,
753774 order: VecDeque<(MediaId, u64)>,
754775 next_stamp: u64,
755- limit: usize,
776+ limit: Option<usize>,
756777 }
757778
758-impl MediaRegistry {
759- fn new(limit: usize) -> Self {
760- Self {
761- entries: HashMap::new(),
762- order: VecDeque::new(),
763- next_stamp: 0,
764- limit,
779+impl ScopeEntries {
780+ fn touch(&mut self, id: &MediaId) {
781+ if self.limit.is_none() {
782+ self.stamps.insert(id.clone(), 0);
783+ return;
765784 }
785+ let stamp = self.next_stamp;
786+ self.next_stamp += 1;
787+ self.stamps.insert(id.clone(), stamp);
788+ self.order.push_back((id.clone(), stamp));
766789 }
767790
768- fn get(&self, id: &MediaId) -> Option<&MediaRegistration> {
769- self.entries.get(id).map(|(media, _)| media)
770- }
771-
772- fn insert_all(&mut self, media: Vec<MediaRegistration>) {
773- for media in media {
774- let stamp = self.next_stamp;
775- self.next_stamp += 1;
776- self.order.push_back((media.id.clone(), stamp));
777- self.entries.insert(media.id.clone(), (media, stamp));
778- }
779- while self.entries.len() > self.limit {
791+ /// what fell out past the limit
792+ fn trim(&mut self) -> Vec<MediaId> {
793+ let Some(limit) = self.limit else {
794+ return Vec::new();
795+ };
796+ let mut dropped = Vec::new();
797+ while self.stamps.len() > limit {
780798 let Some((id, stamp)) = self.order.pop_front() else {
781799 break;
782800 };
783- if self.is_current(&id, stamp) {
784- self.entries.remove(&id);
801+ if self.stamps.get(&id) == Some(&stamp) {
802+ self.stamps.remove(&id);
803+ dropped.push(id);
785804 }
786805 }
787- if self.order.len() > self.limit.saturating_mul(4) {
788- let entries = &self.entries;
806+ if self.order.len() > limit.saturating_mul(4) {
807+ let stamps = &self.stamps;
789808 self.order
790- .retain(|(id, stamp)| entries.get(id).is_some_and(|(_, current)| current == stamp));
809+ .retain(|(id, stamp)| stamps.get(id) == Some(stamp));
791810 }
811+ dropped
792812 }
813+}
793814
794- fn is_current(&self, id: &MediaId, stamp: u64) -> bool {
795- self.entries
796- .get(id)
797- .is_some_and(|(_, current)| *current == stamp)
815+impl MediaRegistry {
816+ fn get(&mut self, id: &MediaId) -> Option<&MediaRegistration> {
817+ let entry = self.entries.get(id)?;
818+ for owner in &entry.owners {
819+ if let Some(scope) = self.scopes.get_mut(owner) {
820+ scope.touch(id);
821+ }
822+ }
823+ Some(&entry.media)
824+ }
825+
826+ fn insert_all(&mut self, scope: &MediaScope, media: Vec<MediaRegistration>) {
827+ let owner = scope.nonce();
828+ let entries = self.scopes.entry(owner).or_insert_with(|| ScopeEntries {
829+ limit: scope.limit(),
830+ ..ScopeEntries::default()
831+ });
832+ for media in media {
833+ let id = media.id.clone();
834+ entries.touch(&id);
835+ let entry = self.entries.entry(id).or_insert_with(|| Entry {
836+ media: media.clone(),
837+ owners: Vec::new(),
838+ });
839+ entry.media = media;
840+ if !entry.owners.contains(&owner) {
841+ entry.owners.push(owner);
842+ }
843+ }
844+ for id in entries.trim() {
845+ Self::disown(&mut self.entries, &id, owner);
846+ }
847+ }
848+
849+ fn disown(entries: &mut HashMap<MediaId, Entry>, id: &MediaId, owner: u64) {
850+ let Some(entry) = entries.get_mut(id) else {
851+ return;
852+ };
853+ entry.owners.retain(|current| *current != owner);
854+ if entry.owners.is_empty() {
855+ entries.remove(id);
856+ }
798857 }
799858
800859 fn release(&mut self, scope: &MediaScope) {
801- self.entries.retain(|id, _| !scope.owns(id));
802- let entries = &self.entries;
803- self.order.retain(|(id, _)| entries.contains_key(id));
860+ let Some(released) = self.scopes.remove(&scope.nonce()) else {
861+ return;
862+ };
863+ for id in released.stamps.keys() {
864+ Self::disown(&mut self.entries, id, scope.nonce());
865+ }
804866 }
805867
806868 fn clear(&mut self) {
807869 self.entries.clear();
808- self.order.clear();
870+ self.scopes.clear();
871+ }
872+}
873+
874+#[cfg(test)]
875+mod tests {
876+ use super::*;
877+ use crate::MediaKind;
878+
879+ fn media(name: &str) -> MediaRegistration {
880+ let uri = ruma::OwnedMxcUri::from(format!("mxc://example.org/{name}"));
881+ MediaRegistration {
882+ id: MediaId::new(format!("test#{name}")),
883+ kind: MediaKind::Image,
884+ full: MediaSourceInfo {
885+ source: MediaSource::Plain(uri),
886+ mimetype: None,
887+ declared_size: None,
888+ },
889+ thumbnail: None,
890+ }
891+ }
892+
893+ fn has(registry: &mut MediaRegistry, name: &str) -> bool {
894+ registry.get(&media(name).id).is_some()
895+ }
896+
897+ #[test]
898+ fn shared_media_lives_until_every_owner_lets_go() {
899+ let mut registry = MediaRegistry::default();
900+ let (timeline, context) = (MediaScope::new(), MediaScope::bounded(8));
901+ registry.insert_all(&timeline, vec![media("shared"), media("row")]);
902+ registry.insert_all(&context, vec![media("shared"), media("page")]);
903+ registry.release(&context);
904+ assert!(has(&mut registry, "shared"));
905+ assert!(has(&mut registry, "row"));
906+ assert!(!has(&mut registry, "page"));
907+ registry.release(&timeline);
908+ assert!(!has(&mut registry, "shared"));
909+ assert!(registry.entries.is_empty());
910+ }
911+
912+ #[test]
913+ fn heavy_context_paging_trims_only_its_own_least_recent_entries() {
914+ let mut registry = MediaRegistry::default();
915+ let (timeline, context) = (MediaScope::new(), MediaScope::bounded(4));
916+ registry.insert_all(&timeline, vec![media("on-screen"), media("both")]);
917+ registry.insert_all(&context, vec![media("both"), media("kept")]);
918+ for page in 0..50 {
919+ assert!(has(&mut registry, "kept"));
920+ registry.insert_all(&context, vec![media(&format!("page-{page}"))]);
921+ }
922+ assert!(has(&mut registry, "on-screen"));
923+ assert!(has(&mut registry, "both"));
924+ assert!(has(&mut registry, "kept"));
925+ assert!(!has(&mut registry, "page-0"));
926+ assert!(registry.scopes[&context.nonce()].stamps.len() <= 4);
927+ registry.release(&context);
928+ assert!(has(&mut registry, "both"));
929+ assert!(!has(&mut registry, "kept"));
809930 }
810931 }
Modified file: src/engine/mod.rs+22 additions−19 deletions
@@ -64,7 +64,8 @@ pub struct EngineConfig {
6464 /// what a thread paginates as it opens, and a room if its recent load fails. 0 for neither
6565 pub initial_history_limit: u16,
6666 pub max_media_bytes: usize,
67- pub max_media_sources: usize,
67+ /// media one event context session keeps registered, least recently used dropped first
68+ pub max_context_media: usize,
6869 pub max_mentions_bytes: usize,
6970 pub max_mentions_entries: usize,
7071 pub max_mention_context_events: usize,
@@ -123,8 +124,8 @@ impl EngineConfig {
123124 }
124125
125126 #[must_use]
126- pub const fn with_max_media_sources(mut self, sources: usize) -> Self {
127- self.max_media_sources = sources;
127+ pub const fn with_max_context_media(mut self, media: usize) -> Self {
128+ self.max_context_media = media;
128129 self
129130 }
130131
@@ -142,7 +143,7 @@ impl Default for EngineConfig {
142143 event_capacity: 128,
143144 initial_history_limit: 30,
144145 max_media_bytes: 20 * 1024 * 1024,
145- max_media_sources: 2_048,
146+ max_context_media: 1_024,
146147 max_mentions_bytes: 64 * 1024 * 1024,
147148 max_mentions_entries: 5_000,
148149 max_mention_context_events: 25_000,
@@ -302,6 +303,7 @@ pub struct Engine {
302303 port: DriverPort,
303304 config: EngineConfig,
304305 media: Arc<MediaService>,
306+ contexts: Arc<context::Sessions>,
305307 tasks: TaskRegistry,
306308 rooms: HashMap<OwnedRoomId, RoomSlot>,
307309 next_generation: u64,
@@ -351,7 +353,7 @@ impl Engine {
351353 }
352354 if config.command_capacity == 0
353355 || config.event_capacity == 0
354- || config.max_media_sources == 0
356+ || config.max_context_media == 0
355357 || config.max_mentions_entries == 0
356358 || config.max_mention_context_events == 0
357359 || config.max_mention_candidates == 0
@@ -362,7 +364,7 @@ impl Engine {
362364 let media = Arc::new(MediaService::new(
363365 client.clone(),
364366 config.max_media_bytes,
365- config.max_media_sources,
367+ config.max_context_media,
366368 ));
367369 let (mentions, mentions_service, mentions_updates) =
368370 crate::mentions::service::Service::new(
@@ -407,6 +409,7 @@ impl Engine {
407409 port,
408410 config,
409411 media,
412+ contexts: Arc::default(),
410413 tasks: TaskRegistry {
411414 tasks: TaskSet::new(),
412415 },
@@ -535,7 +538,7 @@ impl Engine {
535538 let client = &self.client;
536539 let events = || self.port.event_sender();
537540 let (notices, notice_queue) = mpsc::unbounded_channel();
538- let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 13] = [
541+ let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 14] = [
539542 (
540543 "upload tracker",
541544 Box::pin(watchers::uploads(
@@ -600,6 +603,14 @@ impl Engine {
600603 Box::pin(watchers::rooms(client.clone(), events())),
601604 ),
602605 (
606+ "event context watcher",
607+ Box::pin(context::close_on_leave(
608+ client.clone(),
609+ self.media.clone(),
610+ self.contexts.clone(),
611+ )),
612+ ),
613+ (
603614 "image pack watcher",
604615 Box::pin(watchers::image_packs(client.clone(), events())),
605616 ),
@@ -815,20 +826,11 @@ impl Engine {
815826 ),
816827 Command::Timeline(command) => self.route_timeline(command, reply),
817828 Command::EventContext(command) => {
818- let room_id = match &command {
819- crate::EventContextCommand::Fetch { room_id, .. }
820- | crate::EventContextCommand::Before { room_id, .. }
821- | crate::EventContextCommand::After { room_id, .. } => room_id,
822- };
823- let Some(room) = client.get_room(room_id) else {
824- reply.done(Err(Error::room_unknown()));
825- return None;
826- };
827- let current_user_id = client.user_id().map(ToOwned::to_owned);
828- let media = self.media.clone();
829+ let (client, media, contexts) =
830+ (client.clone(), self.media.clone(), self.contexts.clone());
829831 self.tasks
830832 .spawn(TaskContext::Command("event context"), async move {
831- reply.send(context::execute(room, current_user_id, media, command).await);
833+ reply.send(context::execute(client, media, contexts, command).await);
832834 });
833835 }
834836 Command::Mentions(command) => {
@@ -1146,6 +1148,7 @@ impl Engine {
11461148 }
11471149 self.tasks.shutdown().await;
11481150 self.rooms.clear();
1151+ self.contexts.clear().await;
11491152 self.media.clear().await;
11501153 self.port
11511154 .connection_sender()
Modified file: src/engine/timeline.rs+2 additions−2 deletions
@@ -417,7 +417,7 @@ async fn start_watcher(
417417 let current_user_id = context.current_user_id.clone();
418418 let (normalised, mut hidden) =
419419 normalise_timeline(items.iter(), current_user_id.as_deref(), scope);
420- context.media.register(normalised.media).await;
420+ context.media.register(scope, normalised.media).await;
421421 context
422422 .events
423423 .send(Event::TimelineReset {
@@ -462,7 +462,7 @@ async fn start_watcher(
462462 &scope,
463463 &mut hidden,
464464 );
465- media.register(normalised.media).await;
465+ media.register(&scope, normalised.media).await;
466466 replies.fetch(normalised.missing_replies);
467467 if normalised.diffs.is_empty() {
468468 continue;
Modified file: src/error.rs+1 additions
@@ -53,6 +53,7 @@ pub enum ErrorKind {
5353 RoomUnknown,
5454 RoomNotOpen,
5555 TimelineNotOpen,
56+ EventContextNotOpen,
5657 EventNotFound,
5758 RoomBusy,
5859 QueueFull,
Modified file: src/lib.rs+2 additions−2 deletions
@@ -57,8 +57,8 @@ pub use mirror::{
5757 pub use model::{
5858 AccountCommand, AttachmentEncryption, AttachmentUpload, Change, Command, CommandOutput,
5959 ConnectionState, CreateRoomOptions, EncryptionCommand, EncryptionState, EngineErrorKind,
60- EngineFailure, Event, EventContext, EventContextCommand, EventContextPage, EventRef,
61- HistoryState, ImagePack, ImagePackSource, ImagePacks, KeyBackupState, LatestEvent,
60+ EngineFailure, Event, EventContext, EventContextCommand, EventContextPage, EventContextSession,
61+ EventRef, HistoryState, ImagePack, ImagePackSource, ImagePacks, KeyBackupState, LatestEvent,
6262 LifecycleCommand, LinkPreview, LinkPreviewImage, Media, MediaCommand, MediaDescriptor,
6363 MediaFile, MediaId, MediaKind, MediaLocation, MediaSize, MemberFilter, MemberMembership,
6464 MemberSummary, MembershipChange, Message, MessageContent, MessageEditContent, MessageKind,
Modified file: src/mentions/service.rs+11 additions−3 deletions
@@ -30,7 +30,7 @@ use crate::{
3030 MentionGapReason, MentionKey, MentionReason, MentionsCommand, MentionsPage, MentionsUpdate,
3131 TimelineContent, TimelineItem,
3232 engine::{MediaService, MentionsStore, context, executor::EngineBoxFuture},
33- normalise::{MediaRegistration, normalise_observed},
33+ normalise::{MediaRegistration, MediaScope, normalise_observed},
3434 transport::EventSender,
3535 };
3636
@@ -60,6 +60,8 @@ pub(crate) struct Service {
6060 client: Client,
6161 account: OwnedUserId,
6262 media: Arc<MediaService>,
63+ /// as many media as the inbox keeps events
64+ media_scope: MediaScope,
6365 store: Option<MentionsStore>,
6466 data: Data,
6567 sync: SyncState,
@@ -231,8 +233,10 @@ impl Service {
231233 data.gap(None, *reason, None);
232234 }
233235 let page = data.page(&account, None, 50)?;
236+ let media_scope = MediaScope::bounded(config.max_mention_context_events);
234237 media
235238 .register(
239+ &media_scope,
236240 page.entries
237241 .iter()
238242 .filter_map(|entry| data.events.get(&id(&entry.key)))
@@ -247,6 +251,7 @@ impl Service {
247251 client,
248252 account,
249253 media,
254+ media_scope,
250255 store,
251256 data,
252257 sync,
@@ -291,6 +296,7 @@ impl Service {
291296 };
292297 self.media
293298 .register(
299+ &self.media_scope,
294300 page.entries
295301 .iter()
296302 .filter_map(|entry| self.data.events.get(&id(&entry.key)))
@@ -387,6 +393,7 @@ impl Service {
387393 let page = self.data.page(&self.account, cursor.as_deref(), limit)?;
388394 self.media
389395 .register(
396+ &self.media_scope,
390397 page.entries
391398 .iter()
392399 .filter_map(|entry| self.data.events.get(&id(&entry.key)))
@@ -398,7 +405,7 @@ impl Service {
398405 }
399406 MentionsCommand::Context(key) => {
400407 let (context, media) = self.data.context(&key)?;
401- self.media.register(media).await;
408+ self.media.register(&self.media_scope, media).await;
402409 Ok(CommandOutput::MentionContext(context))
403410 }
404411 MentionsCommand::RetryContext(key) => {
@@ -429,7 +436,7 @@ impl Service {
429436 });
430437 self.apply(next).await?;
431438 let (context, media) = self.data.context(&key)?;
432- self.media.register(media).await;
439+ self.media.register(&self.media_scope, media).await;
433440 Ok(CommandOutput::MentionContext(context))
434441 }
435442 MentionsCommand::SetRead { keys, read } => {
@@ -1007,6 +1014,7 @@ impl Service {
10071014 if self.dirty && let Err(error) = self.flush().await {
10081015 tracing::error!(%error, "saving the mentions inbox at shutdown failed");
10091016 }
1017+ self.media.release(&self.media_scope).await;
10101018 let _ = reply.send(());
10111019 return;
10121020 }
Modified file: src/mentions/service_tests.rs+4 additions−4 deletions
@@ -113,7 +113,7 @@ async fn service(client: &Client, store: Option<Result<MentionsStore, Error>>) -
113113 let media = Arc::new(MediaService::new(
114114 client.clone(),
115115 EngineConfig::default().max_media_bytes,
116- EngineConfig::default().max_media_sources,
116+ EngineConfig::default().max_context_media,
117117 ));
118118 let (_, mut service, _) = Service::new(client.clone(), media, store, EngineConfig::default())
119119 .await
@@ -412,7 +412,7 @@ async fn service_recovers_notification_history_into_durable_unread_inbox() {
412412 let media = Arc::new(MediaService::new(
413413 client.clone(),
414414 EngineConfig::default().max_media_bytes,
415- EngineConfig::default().max_media_sources,
415+ EngineConfig::default().max_context_media,
416416 ));
417417 let (handle, service, mut updates) = Service::new(
418418 client.clone(),
@@ -504,7 +504,7 @@ async fn live_context_survives_capture_then_edit_and_redaction() {
504504 let media = Arc::new(MediaService::new(
505505 client.clone(),
506506 EngineConfig::default().max_media_bytes,
507- EngineConfig::default().max_media_sources,
507+ EngineConfig::default().max_context_media,
508508 ));
509509 let (_handle, mut service, _updates) =
510510 Service::new(client, media, None, EngineConfig::default())
@@ -622,7 +622,7 @@ async fn explicit_recovery_rechecks_server_support() {
622622 let media = Arc::new(MediaService::new(
623623 client.clone(),
624624 config.max_media_bytes,
625- config.max_media_sources,
625+ config.max_context_media,
626626 ));
627627 let (_, mut service, _) = Service::new(client, media, None, config).await.unwrap();
628628 service.data.coverage.historical_supported = Some(false);
Modified file: src/model.rs+17 additions−3 deletions
@@ -37,6 +37,8 @@ pub enum Command {
3737 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
3838 #[non_exhaustive]
3939 pub enum EventContextCommand {
40+ /// opens a session that owns the media of everything it hands out. at most 32 stay
41+ /// open, opening another closes the oldest, and leaving the room closes its sessions
4042 Fetch {
4143 room_id: OwnedRoomId,
4244 event_id: OwnedEventId,
@@ -44,15 +46,17 @@ pub enum EventContextCommand {
4446 after_limit: u16,
4547 },
4648 Before {
47- room_id: OwnedRoomId,
49+ context_id: String,
4850 limit: u16,
4951 token: String,
5052 },
5153 After {
52- room_id: OwnedRoomId,
54+ context_id: String,
5355 limit: u16,
5456 token: String,
5557 },
58+ /// frees the session's media. its rows' media ids stop resolving
59+ Close { context_id: String },
5660 }
5761
5862 #[derive(Debug)]
@@ -668,7 +672,7 @@ pub enum CommandOutput {
668672 shortcodes: Vec<String>,
669673 skipped: Vec<String>,
670674 },
671- EventContext(EventContext),
675+ EventContext(EventContextSession),
672676 EventContextPage(EventContextPage),
673677 MentionsPage(crate::MentionsPage),
674678 MentionContext(crate::SavedMentionContext),
@@ -685,6 +689,16 @@ pub struct EventContext {
685689 pub end_token: Option<String>,
686690 }
687691
692+/// a session's media stay resolvable until it closes, and past `EngineConfig::max_context_media`
693+/// its least recently used are dropped
694+#[derive(Clone, Debug, Eq, PartialEq)]
695+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
696+#[non_exhaustive]
697+pub struct EventContextSession {
698+ pub context_id: String,
699+ pub context: EventContext,
700+}
701+
688702 #[derive(Clone, Debug, Eq, PartialEq)]
689703 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
690704 #[non_exhaustive]
Modified file: src/normalise/media.rs+20 additions−4 deletions
@@ -24,22 +24,38 @@ static NEXT_MEDIA_SCOPE: AtomicU64 = AtomicU64::new(1);
2424 /// echo replacement, while transaction IDs disappear. Scoping the recycled ID
2525 /// by a process-wide nonce keeps media IDs stable across replacement without
2626 /// colliding across rooms, threads, or reopened sessions.
27+///
28+/// It is also the owner its registrations belong to. Media stays registered while any
29+/// scope that registered it is open, and a bounded scope drops its least recently used
30+/// entries past its limit without touching anyone else's.
2731 #[derive(Clone, Debug)]
2832 pub(crate) struct MediaScope {
2933 nonce: u64,
34+ limit: Option<usize>,
3035 }
3136
3237 impl MediaScope {
38+ /// for a timeline, which holds what is on screen and so is never trimmed
3339 pub(crate) fn new() -> Self {
3440 Self {
3541 nonce: NEXT_MEDIA_SCOPE.fetch_add(1, Ordering::Relaxed),
42+ limit: None,
3643 }
3744 }
3845
39- pub(crate) fn owns(&self, id: &MediaId) -> bool {
40- id.as_str()
41- .split_once('#')
42- .is_some_and(|(nonce, _)| nonce.parse() == Ok(self.nonce))
46+ pub(crate) fn bounded(limit: usize) -> Self {
47+ Self {
48+ limit: Some(limit.max(1)),
49+ ..Self::new()
50+ }
51+ }
52+
53+ pub(crate) const fn nonce(&self) -> u64 {
54+ self.nonce
55+ }
56+
57+ pub(crate) const fn limit(&self) -> Option<usize> {
58+ self.limit
4359 }
4460 }
4561
Modified file: src/transport.rs+23 additions−15 deletions
@@ -28,15 +28,15 @@ use tokio::sync::{OnceCell, mpsc, oneshot, watch};
2828
2929 use crate::{
3030 AccountCommand, AttachmentUpload, Command, CommandOutput, ConnectionState, CreateRoomOptions,
31- EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventContext, EventContextCommand,
32- EventContextPage, EventRef, HistoryRepairCommand, ImagePackSource, ImagePacks, InteractiveAuth,
33- InteractiveAuthOutcome, LifecycleCommand, LinkPreview, Media, MediaCommand, MediaId,
34- MediaLocation, MediaSize, MemberFilter, MemberSummary, MentionKey, MentionsBoundary,
35- MentionsCommand, MentionsPage, MessageContent, MessageEditContent, NotificationCommand,
36- OwnDevice, PackCommand, PackImageInfo, PackUsage, Patch, PowerLevelChanges, Profile,
37- PushNotification, PushNotificationEvent, PushNotificationStatus, RoomCommand, RoomListFilter,
38- RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomSummary,
39- SavedMentionContext, SpaceChild, TimelineCommand, TimelineId, VerificationAction,
31+ EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventContextCommand,
32+ EventContextPage, EventContextSession, EventRef, HistoryRepairCommand, ImagePackSource,
33+ ImagePacks, InteractiveAuth, InteractiveAuthOutcome, LifecycleCommand, LinkPreview, Media,
34+ MediaCommand, MediaId, MediaLocation, MediaSize, MemberFilter, MemberSummary, MentionKey,
35+ MentionsBoundary, MentionsCommand, MentionsPage, MessageContent, MessageEditContent,
36+ NotificationCommand, OwnDevice, PackCommand, PackImageInfo, PackUsage, Patch,
37+ PowerLevelChanges, Profile, PushNotification, PushNotificationEvent, PushNotificationStatus,
38+ RoomCommand, RoomListFilter, RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels,
39+ RoomSummary, SavedMentionContext, SpaceChild, TimelineCommand, TimelineId, VerificationAction,
4040 VerificationTarget,
4141 encryption::encryption_state,
4242 engine::{MediaService, StoreLock},
@@ -358,13 +358,14 @@ impl ClientHandle {
358358 .await
359359 }
360360
361+ /// close the session when the view goes, or its media stay registered
361362 pub async fn event_context(
362363 &self,
363364 room_id: &RoomId,
364365 event_id: &EventId,
365366 before_limit: u16,
366367 after_limit: u16,
367- ) -> Result<EventContext, Error> {
368+ ) -> Result<EventContextSession, Error> {
368369 match self
369370 .execute(Command::EventContext(EventContextCommand::Fetch {
370371 room_id: room_id.to_owned(),
@@ -374,19 +375,19 @@ impl ClientHandle {
374375 }))
375376 .await?
376377 {
377- CommandOutput::EventContext(context) => Ok(context),
378+ CommandOutput::EventContext(session) => Ok(session),
378379 output => Err(unexpected("event context", &output)),
379380 }
380381 }
381382
382383 pub async fn event_context_before(
383384 &self,
384- room_id: &RoomId,
385+ context_id: &str,
385386 token: &str,
386387 limit: u16,
387388 ) -> Result<EventContextPage, Error> {
388389 self.event_context_page(EventContextCommand::Before {
389- room_id: room_id.to_owned(),
390+ context_id: context_id.to_owned(),
390391 limit,
391392 token: token.to_owned(),
392393 })
@@ -395,18 +396,25 @@ impl ClientHandle {
395396
396397 pub async fn event_context_after(
397398 &self,
398- room_id: &RoomId,
399+ context_id: &str,
399400 token: &str,
400401 limit: u16,
401402 ) -> Result<EventContextPage, Error> {
402403 self.event_context_page(EventContextCommand::After {
403- room_id: room_id.to_owned(),
404+ context_id: context_id.to_owned(),
404405 limit,
405406 token: token.to_owned(),
406407 })
407408 .await
408409 }
409410
411+ pub async fn close_event_context(&self, context_id: &str) -> Result<(), Error> {
412+ self.run(Command::EventContext(EventContextCommand::Close {
413+ context_id: context_id.to_owned(),
414+ }))
415+ .await
416+ }
417+
410418 async fn event_context_page(
411419 &self,
412420 command: EventContextCommand,
Modified file: tests/mentions_inbox.rs+11 additions−1 deletions
@@ -717,12 +717,22 @@ async fn event_context_uses_independent_direction_limits_and_saved_mention_conte
717717 .count();
718718 assert_eq!(after_saved_request_count, before_saved_request_count);
719719
720- let context = handle
720+ let session = handle
721721 .event_context(room_id, context_id, 2, 3)
722722 .await
723723 .unwrap();
724+ let context = &session.context;
724725 assert_eq!(context.start_token.as_deref(), Some("manual-before-next"));
725726 assert_eq!(context.end_token.as_deref(), Some("manual-after-next"));
727+ handle
728+ .close_event_context(&session.context_id)
729+ .await
730+ .unwrap();
731+ let closed = handle
732+ .event_context_before(&session.context_id, "manual-before-next", 5)
733+ .await
734+ .unwrap_err();
735+ assert_eq!(closed.kind, macltok::ErrorKind::EventContextNotOpen);
726736
727737 let requests = server.server().received_requests().await.unwrap();
728738 let context_limits: Vec<_> = requests