atagen

macltok

public
Star0
Watch0

Commit 1f875b103b27

tarball

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

7 files changed+148-51
context: announce sessions closed behind the consumer and keep owners' media apart

A session evicted by the 33rd open or by leaving the room vanished silently, so the next page was the first anyone heard of it. Context media ids now carry their session, because they shared the mentions inbox's ids for the same event and the newer registration's content won. Repeated reads no longer grow a bounded scope's order without limit, and an edit that is still encrypted no longer masks an earlier readable one.
Modified file: src/engine/context.rs+104 additions−36 deletions
@@ -13,13 +13,14 @@ use serde_json::Value;
1313 use tokio::sync::{Mutex, broadcast::error::RecvError};
1414
1515 use crate::{
16- Error, ErrorKind, EventContext, EventContextCommand, EventContextPage, EventContextSession,
17- MediaId, Sender, TimelineEvent, TimelineItem,
16+ Error, ErrorKind, Event, EventContext, EventContextCloseReason, EventContextCommand,
17+ EventContextPage, EventContextSession, MediaId, Sender, TimelineEvent, TimelineItem,
1818 engine::MediaService,
1919 normalise::{
2020 MediaRegistration, MediaScope, apply_profiles, normalise_context, normalise_observed,
2121 resolve_replies,
2222 },
23+ transport::EventSender,
2324 };
2425
2526 const MAX_CONTEXT_LIMIT: u16 = 50;
@@ -27,9 +28,9 @@ const MAX_CONTEXT_LIMIT: u16 = 50;
2728 const MAX_SESSIONS: usize = 32;
2829
2930 /// open event contexts, oldest first, each owning the media its pages registered
30-#[derive(Default)]
3131 pub(crate) struct Sessions {
3232 open: Mutex<VecDeque<Session>>,
33+ events: EventSender,
3334 }
3435
3536 struct Session {
@@ -38,36 +39,61 @@ struct Session {
3839 scope: MediaScope,
3940 }
4041
42+impl Session {
43+ /// media ids carry the session, so two owners of one event never share an entry
44+ fn media_prefix(scope: &MediaScope) -> String {
45+ format!("{}#", scope.nonce())
46+ }
47+}
48+
4149 impl Sessions {
50+ pub(crate) fn new(events: EventSender) -> Self {
51+ Self {
52+ open: Mutex::default(),
53+ events,
54+ }
55+ }
56+
4257 async fn open(
4358 &self,
4459 media: &MediaService,
4560 room_id: OwnedRoomId,
61+ scope: MediaScope,
4662 registrations: Vec<MediaRegistration>,
4763 ) -> String {
48- let scope = media.context_scope();
4964 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;
65+ let mut evicted = Vec::new();
66+ {
67+ let mut open = self.open.lock().await;
68+ media.register(&scope, registrations).await;
69+ open.push_back(Session {
70+ id: id.clone(),
71+ room_id,
72+ scope,
73+ });
74+ while open.len() > MAX_SESSIONS {
75+ if let Some(oldest) = open.pop_front() {
76+ media.release(&oldest.scope).await;
77+ evicted.push(oldest.id);
78+ }
6079 }
6180 }
81+ self.announce(evicted, EventContextCloseReason::Evicted)
82+ .await;
6283 id
6384 }
6485
65- async fn room(&self, id: &str) -> Result<OwnedRoomId, Error> {
86+ /// the room and the media prefix the session's pages use
87+ async fn find(&self, id: &str) -> Result<(OwnedRoomId, String), Error> {
6688 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)
89+ let session = open
90+ .iter()
91+ .find(|session| session.id == id)
92+ .ok_or_else(not_open)?;
93+ Ok((
94+ session.room_id.clone(),
95+ Session::media_prefix(&session.scope),
96+ ))
7197 }
7298
7399 /// under the lock, so a page that lands after its close registers nothing
@@ -99,19 +125,37 @@ impl Sessions {
99125 }
100126
101127 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);
128+ let mut closed = Vec::new();
129+ {
130+ let mut open = self.open.lock().await;
131+ let mut kept = VecDeque::with_capacity(open.len());
132+ for session in open.drain(..) {
133+ let left = client.get_room(&session.room_id).is_none_or(|room| {
134+ matches!(room.state(), RoomState::Left | RoomState::Banned)
135+ });
136+ if left {
137+ media.release(&session.scope).await;
138+ closed.push(session.id);
139+ } else {
140+ kept.push_back(session);
141+ }
142+ }
143+ *open = kept;
144+ }
145+ self.announce(closed, EventContextCloseReason::RoomLeft)
146+ .await;
147+ }
148+
149+ async fn announce(&self, ids: Vec<String>, reason: EventContextCloseReason) {
150+ for context_id in ids {
151+ let event = Event::EventContextClosed {
152+ context_id,
153+ reason: reason.clone(),
154+ };
155+ if self.events.send(event).await.is_err() {
156+ return;
112157 }
113158 }
114- *open = kept;
115159 }
116160
117161 /// the media service is cleared beside this, so nothing is released one by one
@@ -157,15 +201,17 @@ pub(crate) async fn execute(
157201 before_limit,
158202 after_limit,
159203 } => {
160- let (context, registrations, _) = capture(
204+ let scope = media.context_scope();
205+ let (context, registrations, _) = capture_in(
161206 room(&room_id)?,
162207 current_user_id,
163208 event_id,
164209 before_limit,
165210 after_limit,
211+ &Session::media_prefix(&scope),
166212 )
167213 .await?;
168- let context_id = sessions.open(&media, room_id, registrations).await;
214+ let context_id = sessions.open(&media, room_id, scope, registrations).await;
169215 return Ok(crate::CommandOutput::EventContext(EventContextSession {
170216 context_id,
171217 context,
@@ -199,7 +245,8 @@ pub(crate) async fn execute(
199245 "context pagination token is empty",
200246 ));
201247 }
202- let room = room(&sessions.room(&context_id).await?)?;
248+ let (room_id, media_prefix) = sessions.find(&context_id).await?;
249+ let room = room(&room_id)?;
203250 let side = fetch_side(&room, Some(&token), limit, backwards).await?;
204251 let mut page = Assembly::new(
205252 &room,
@@ -207,6 +254,7 @@ pub(crate) async fn execute(
207254 &side.values,
208255 &side.highlights,
209256 None,
257+ &media_prefix,
210258 )
211259 .await;
212260 let events = page.take(0..side.values.len());
@@ -224,6 +272,17 @@ pub(crate) async fn capture(
224272 before_limit: u16,
225273 after_limit: u16,
226274 ) -> Result<(EventContext, Vec<MediaRegistration>, Vec<Value>), Error> {
275+ capture_in(room, current_user_id, event_id, before_limit, after_limit, "").await
276+}
277+
278+async fn capture_in(
279+ room: Room,
280+ current_user_id: Option<OwnedUserId>,
281+ event_id: OwnedEventId,
282+ before_limit: u16,
283+ after_limit: u16,
284+ media_prefix: &str,
285+) -> Result<(EventContext, Vec<MediaRegistration>, Vec<Value>), Error> {
227286 validate_limit(before_limit)?;
228287 validate_limit(after_limit)?;
229288 let raw = room
@@ -264,6 +323,7 @@ pub(crate) async fn capture(
264323 &values,
265324 &highlights,
266325 Some(target_index),
326+ media_prefix,
267327 )
268328 .await;
269329 let events_before = page.take(0..target_index);
@@ -357,8 +417,15 @@ impl Assembly {
357417 values: &[Value],
358418 highlights: &[bool],
359419 keep: Option<usize>,
420+ media_prefix: &str,
360421 ) -> Self {
361- let page = normalise_context(values, keep, current_user_id, room.room_id());
422+ let page = normalise_context(
423+ values,
424+ keep,
425+ current_user_id,
426+ room.room_id(),
427+ media_prefix,
428+ );
362429 let mut items = page.items;
363430 for (index, item) in &mut items {
364431 if let TimelineItem::Event(event) = item {
@@ -386,7 +453,7 @@ impl Assembly {
386453 let fetched = join_all(
387454 missing
388455 .into_iter()
389- .map(|event_id| fetch_reply(room, current_user_id, event_id)),
456+ .map(|event_id| fetch_reply(room, current_user_id, event_id, media_prefix)),
390457 )
391458 .await;
392459 for (event, registrations) in fetched.into_iter().flatten() {
@@ -433,6 +500,7 @@ async fn fetch_reply(
433500 room: &Room,
434501 current_user_id: Option<&ruma::UserId>,
435502 event_id: OwnedEventId,
503+ media_prefix: &str,
436504 ) -> Option<(TimelineEvent, Vec<MediaRegistration>)> {
437505 let event = room
438506 .load_or_fetch_event(&event_id, None)
@@ -440,7 +508,7 @@ async fn fetch_reply(
440508 .inspect_err(|error| tracing::debug!(%event_id, %error, "fetching a reply target failed"))
441509 .ok()?;
442510 let value: Value = serde_json::from_str(event.raw().json().get()).ok()?;
443- let identity = format!("{}\n{event_id}", room.room_id());
511+ let identity = format!("{media_prefix}{}\n{event_id}", room.room_id());
444512 match normalise_observed(&value, current_user_id, &identity)? {
445513 (TimelineItem::Event(event), media) => Some((*event, media)),
446514 _ => None,
Modified file: src/engine/media.rs+7 additions−7 deletions
@@ -778,14 +778,19 @@ struct ScopeEntries {
778778
779779 impl ScopeEntries {
780780 fn touch(&mut self, id: &MediaId) {
781- if self.limit.is_none() {
781+ let Some(limit) = self.limit else {
782782 self.stamps.insert(id.clone(), 0);
783783 return;
784- }
784+ };
785785 let stamp = self.next_stamp;
786786 self.next_stamp += 1;
787787 self.stamps.insert(id.clone(), stamp);
788788 self.order.push_back((id.clone(), stamp));
789+ if self.order.len() > limit.saturating_mul(4) {
790+ let stamps = &self.stamps;
791+ self.order
792+ .retain(|(id, stamp)| stamps.get(id) == Some(stamp));
793+ }
789794 }
790795
791796 /// what fell out past the limit
@@ -803,11 +808,6 @@ impl ScopeEntries {
803808 dropped.push(id);
804809 }
805810 }
806- if self.order.len() > limit.saturating_mul(4) {
807- let stamps = &self.stamps;
808- self.order
809- .retain(|(id, stamp)| stamps.get(id) == Some(stamp));
810- }
811811 dropped
812812 }
813813 }
Modified file: src/engine/mod.rs+1 additions−1 deletions
@@ -404,12 +404,12 @@ impl Engine {
404404 let (notifications, notification_queue) = mpsc::channel(config.command_capacity);
405405 let (encryption, encryption_queue) = mpsc::channel(config.command_capacity);
406406 let engine = Self {
407+ contexts: Arc::new(context::Sessions::new(port.event_sender())),
407408 client,
408409 sync,
409410 port,
410411 config,
411412 media,
412- contexts: Arc::default(),
413413 tasks: TaskRegistry {
414414 tasks: TaskSet::new(),
415415 },
Modified file: src/lib.rs+1 additions−1 deletions
@@ -57,7 +57,7 @@ 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, EventContextSession,
60+ EngineFailure, Event, EventContext, EventContextCloseReason, EventContextCommand, EventContextPage, EventContextSession,
6161 EventRef, HistoryState, ImagePack, ImagePackSource, ImagePacks, KeyBackupState, LatestEvent,
6262 LifecycleCommand, LinkPreview, LinkPreviewImage, Media, MediaCommand, MediaDescriptor,
6363 MediaFile, MediaId, MediaKind, MediaLocation, MediaSize, MemberFilter, MemberMembership,
Modified file: src/mirror.rs+8 additions−1 deletions
@@ -20,7 +20,7 @@ use tokio::sync::broadcast;
2020
2121 use crate::{
2222 ClientHandle, Command, ConnectionState, EncryptionState, EngineFailure, ErrorKind, Event,
23- HistoryRepairReport, HistoryState, ImagePacks, RecentHistoryState, RoomSummary,
23+ EventContextCloseReason, HistoryRepairReport, HistoryState, ImagePacks, RecentHistoryState, RoomSummary,
2424 TimelineCommand, TimelineDiff, TimelineId, TimelineItem, TypingState, UploadOutcome,
2525 VerificationState, VerificationTarget, engine::executor::EngineBoxFuture,
2626 transport::PublicationRegistration,
@@ -145,6 +145,10 @@ pub enum StateChange {
145145 },
146146 HistoryRepair(HistoryRepairReport),
147147 Typing(TypingState),
148+ EventContextClosed {
149+ context_id: String,
150+ reason: EventContextCloseReason,
151+ },
148152 Upload {
149153 transaction_id: OwnedTransactionId,
150154 upload: UploadState,
@@ -407,6 +411,9 @@ impl Mirror {
407411 })
408412 });
409413 }
414+ Event::EventContextClosed { context_id, reason } => {
415+ self.publish(|_| Some(StateChange::EventContextClosed { context_id, reason }));
416+ }
410417 Event::TypingChanged(typing) => self.publish(|state| {
411418 if typing.users.is_empty() {
412419 state.typing.remove(&typing.room_id);
Modified file: src/model.rs+16 additions−1 deletions
@@ -38,7 +38,8 @@ pub enum Command {
3838 #[non_exhaustive]
3939 pub enum EventContextCommand {
4040 /// 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
41+ /// open, opening another closes the oldest, and leaving the room closes its sessions,
42+ /// both told with `Event::EventContextClosed`
4243 Fetch {
4344 room_id: OwnedRoomId,
4445 event_id: OwnedEventId,
@@ -690,6 +691,14 @@ pub struct EventContext {
690691 pub end_token: Option<String>,
691692 }
692693
694+#[derive(Clone, Debug, Eq, PartialEq)]
695+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
696+pub enum EventContextCloseReason {
697+ /// opening a 33rd session closed the oldest
698+ Evicted,
699+ RoomLeft,
700+}
701+
693702 /// a session's media stay resolvable until it closes, and past `EngineConfig::max_context_media`
694703 /// its least recently used are dropped
695704 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -930,6 +939,12 @@ pub enum Event {
930939 TimelineClosed {
931940 timeline_id: TimelineId,
932941 },
942+ /// an event context closed by something other than its own `Close`. its id answers
943+ /// `EventContextNotOpen` from now on
944+ EventContextClosed {
945+ context_id: String,
946+ reason: EventContextCloseReason,
947+ },
933948 /// a room that stops being watched says so with an empty `users`
934949 TypingChanged(TypingState),
935950 MediaUploadProgress {
Modified file: src/normalise/context.rs+11 additions−4 deletions
@@ -20,12 +20,14 @@ struct Annotation<'a> {
2020
2121 /// raw events in timeline order, shown the way the live room timeline shows them. edits and
2222 /// reactions fold into their targets within the page, and redactions, unmatched relations
23-/// and thread replies go. `keep` is never dropped
23+/// and thread replies go. `keep` is never dropped. `media_prefix` keeps one owner's media ids
24+/// apart from another's for the same event
2425 pub(crate) fn normalise_context(
2526 values: &[Value],
2627 keep: Option<usize>,
2728 current_user_id: Option<&UserId>,
2829 room_id: &RoomId,
30+ media_prefix: &str,
2931 ) -> ContextPage {
3032 let mut edits: HashMap<&str, Vec<&Value>> = HashMap::new();
3133 let mut annotations: HashMap<&str, Vec<Annotation<'_>>> = HashMap::new();
@@ -56,7 +58,12 @@ pub(crate) fn normalise_context(
5658 }
5759 }
5860 (Some("m.reaction"), ..) => {}
59- (_, Some("m.replace"), Some(target)) => edits.entry(target).or_default().push(value),
61+ // an edit still encrypted keeps its relation in the clear but has no new content
62+ (_, Some("m.replace"), Some(target)) => {
63+ if value.pointer("/content/m.new_content").is_some() {
64+ edits.entry(target).or_default().push(value);
65+ }
66+ }
6067 (_, Some("m.thread"), _) => {}
6168 _ => shown.push(index),
6269 }
@@ -89,7 +96,7 @@ pub(crate) fn normalise_context(
8996 .as_object_mut()?
9097 .insert("m.replace".to_owned(), edit.clone());
9198 }
92- let identity = format!("{room_id}\n{event_id}");
99+ let identity = format!("{media_prefix}{room_id}\n{event_id}");
93100 let (mut item, registrations) = normalise_observed(&value, current_user_id, &identity)?;
94101 media.extend(registrations);
95102 if let (TimelineItem::Event(event), Some(annotations)) =
@@ -232,7 +239,7 @@ mod tests {
232239 "content": {},
233240 }),
234241 ];
235- let page = normalise_context(&values, None, Some(user_id!("@a:x")), room_id!("!room:x"));
242+ let page = normalise_context(&values, None, Some(user_id!("@a:x")), room_id!("!room:x"), "");
236243 assert_eq!(page.items.len(), 1);
237244 let TimelineItem::Event(event) = &page.items[0].1 else {
238245 panic!("expected an event");