atagen

macltok

public
Star0
Watch0

Commit 0bfa3f46f0be

tarball

atagen <boss@atagen.co>2026-09-27 01:42 UTC

9 files changed+195-56
engine: say when timelines, typing and uploads end

A consumer keeping state per timeline, per room's typing or per upload had no event for when to drop it, so closes, leaves and cancelled uploads left it guessing. The upload watcher now follows every attachment in the send queue itself rather than asking the room's queue on each failure, because the client-wide channel carries sends and cancels as well and a failed upload can still be retried. Push rule changes don't raise a room info update, so NotificationSettingsChanged now brings a RoomsChanged for the notification modes in RoomSummary.
Modified file: docs/design-decisions.md+14 additions−3 deletions
@@ -58,6 +58,10 @@ kicked or banned from, closes itself. it tells the engine first, and anything
5858 already queued to it comes back to be answered or replayed, so an open racing a
5959 leave still gets a fresh actor
6060
61+however an actor ends, short of the engine stopping, its typing goes out empty
62+and each of its timelines sends `TimelineClosed`. so does a closed thread. a
63+timeline that never sent a reset never sends a close either
64+
6165 opening sends `TimelineReset` on the stream before it answers. opening an
6266 already open room sends a fresh reset, so a second frontend gets a snapshot
6367
@@ -138,8 +142,12 @@ closes, least recently seen first once the cap is hit
138142 uploads are commands that answer once the send queue has them. progress only
139143 goes out on a room's own send queue channel, so each room that uploads gets a
140144 forwarder, subscribed before the upload is queued and restarted for uploads the
141-sdk resumes from last time. failures do reach the client-wide channel, so one
142-watcher there reports them
145+sdk resumes from last time. everything but progress reaches the client-wide
146+channel, so one watcher there follows every attachment in the queue and ends
147+each with one `MediaUploadFinished`. sent, cancelled, its room forgotten, or
148+missed when that channel overflowed at the wrong moment, since then there's no
149+knowing which. a failure isn't an ending, a wedged upload can still be retried.
150+uploads don't belong to a room's actor, so closing the room leaves them be
143151
144152 ## sessions and secrets
145153
@@ -175,7 +183,10 @@ sdk ui types stay behind the boundary
175183
176184 `rooms` normalises on demand with bounded concurrency and sorts by latest event,
177185 then name, then id. `RoomsChanged` is an invalidation, coalesced so one sync
178-touching fifty rooms is one event
186+touching fifty rooms is one event. a left room stays in the list with its
187+membership until it's forgotten. push rule changes never touch room info, so
188+`NotificationSettingsChanged` brings a `RoomsChanged` along for the modes in
189+the summaries
179190
180191 ## verification
181192
Modified file: src/engine/mod.rs+2 additions−2 deletions
@@ -401,8 +401,8 @@ impl Engine {
401401 let events = || self.port.event_sender();
402402 let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 11] = [
403403 (
404- "upload failure watcher",
405- Box::pin(watchers::upload_failures(
404+ "upload watcher",
405+ Box::pin(watchers::uploads(
406406 client.clone(),
407407 client.send_queue().subscribe(),
408408 events(),
Modified file: src/engine/room.rs+11 additions−2 deletions
@@ -91,7 +91,7 @@ impl RoomActor {
9191 reply.done(Ok(()));
9292 }
9393 let (typing_guard, typing) = self.room.subscribe_to_typing_notifications();
94- let typing_watcher = TaskGuard(self.context.supervisor.spawn(
94+ let mut typing_watcher = TaskGuard(self.context.supervisor.spawn(
9595 "typing watcher",
9696 watch_typing(self.room_id.clone(), typing, self.context.events.clone()),
9797 ));
@@ -112,7 +112,16 @@ impl RoomActor {
112112 // let redactions and receipts that already left finish rather than lie about them
113113 while timelines.work.join_next().await.is_some() {}
114114 drop(typing_guard);
115- drop(typing_watcher);
115+ typing_watcher.0.abort();
116+ let _ = (&mut typing_watcher.0).await;
117+ let _ = self
118+ .context
119+ .events
120+ .send(Event::TypingChanged(TypingState {
121+ room_id: self.room_id.clone(),
122+ users: Vec::new(),
123+ }))
124+ .await;
116125 timelines.threads.close().await;
117126 timelines.room.close().await;
118127 if let Some(reply) = close {
Modified file: src/engine/timeline.rs+7 additions
@@ -159,6 +159,13 @@ impl TimelineSession {
159159 let _ = (&mut self.watcher).await;
160160 // after the watcher, or a last diff could register media for a timeline that's gone
161161 self.context.media.release(&self.scope).await;
162+ let _ = self
163+ .context
164+ .events
165+ .send(Event::TimelineClosed {
166+ timeline_id: self.id.clone(),
167+ })
168+ .await;
162169 }
163170
164171 pub(super) async fn execute(
Modified file: src/engine/watchers.rs+117 additions−45 deletions
@@ -1,19 +1,19 @@
1-use std::{sync::Arc, time::Duration};
1+use std::{collections::HashSet, sync::Arc, time::Duration};
22
33 use futures_util::StreamExt;
44 use matrix_sdk::{
55 Client, Room, SessionChange,
66 ruma::events::{AnyMessageLikeEventContent, room::message::MessageType},
7- send_queue::{LocalEchoContent, RoomSendQueueUpdate},
7+ send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendQueueUpdate},
88 };
99 use matrix_sdk_ui::sync_service::{State as SyncState, SyncService};
10-use ruma::{OwnedDeviceId, time::Instant};
10+use ruma::{OwnedDeviceId, OwnedRoomId, OwnedTransactionId, time::Instant};
1111 use tokio::sync::{broadcast::error::RecvError, mpsc, watch};
1212
1313 use super::executor::TaskSet;
1414 use crate::{
15- ConnectionState, Event, Termination, encryption::encryption_state, error::classify_sdk_error,
16- transport::EventSender,
15+ ConnectionState, Event, Termination, UploadOutcome, encryption::encryption_state,
16+ error::classify_sdk_error, transport::EventSender,
1717 };
1818
1919 const SYNC_RESTART_MIN: Duration = Duration::from_secs(1);
@@ -185,54 +185,127 @@ pub(super) async fn room_uploads(room: Room, events: EventSender, ready: watch::
185185 }
186186 }
187187
188-/// failures do reach the client-wide channel, and this one exists before any upload does
189-pub(super) async fn upload_failures(
188+/// progress only goes out on each room's own channel, everything else reaches this
189+/// one, which exists before any upload does
190+pub(super) async fn uploads(
190191 client: Client,
191- mut updates: tokio::sync::broadcast::Receiver<matrix_sdk::send_queue::SendQueueUpdate>,
192+ mut updates: tokio::sync::broadcast::Receiver<SendQueueUpdate>,
192193 events: EventSender,
193194 ) {
195+ let (_, mut rooms) = client.rooms_stream();
196+ let mut uploads = attachment_echoes(&client).await;
194197 loop {
195- let update = match updates.recv().await {
196- Ok(update) => update,
197- Err(RecvError::Lagged(skipped)) => {
198- tracing::warn!(skipped, "upload failure watcher lagged");
199- continue;
198+ let finished = tokio::select! {
199+ update = updates.recv() => match update {
200+ Ok(update) => match upload_update(&mut uploads, update) {
201+ Some(event) => vec![event],
202+ None => continue,
203+ },
204+ Err(RecvError::Lagged(skipped)) => {
205+ tracing::warn!(skipped, "upload watcher lagged");
206+ let current = attachment_echoes(&client).await;
207+ let missed = uploads.difference(&current).cloned().collect::<Vec<_>>();
208+ uploads = current;
209+ finished_uploads(missed, UploadOutcome::Missed)
210+ }
211+ Err(RecvError::Closed) => return,
212+ },
213+ update = rooms.next() => {
214+ if update.is_none() {
215+ return;
216+ }
217+ let forgotten = uploads
218+ .iter()
219+ .filter(|(room_id, _)| client.get_room(room_id).is_none())
220+ .cloned()
221+ .collect::<Vec<_>>();
222+ for upload in &forgotten {
223+ uploads.remove(upload);
224+ }
225+ finished_uploads(forgotten, UploadOutcome::RoomForgotten)
200226 }
201- Err(RecvError::Closed) => return,
202227 };
203- let RoomSendQueueUpdate::SendError {
228+ for event in finished {
229+ if events.send(event).await.is_err() {
230+ return;
231+ }
232+ }
233+ }
234+}
235+
236+type Upload = (OwnedRoomId, OwnedTransactionId);
237+
238+async fn attachment_echoes(client: &Client) -> HashSet<Upload> {
239+ let Ok(echoes) = client.send_queue().local_echoes().await else {
240+ return HashSet::new();
241+ };
242+ echoes
243+ .into_iter()
244+ .flat_map(|(room_id, echoes)| {
245+ echoes
246+ .into_iter()
247+ .filter(|echo| is_attachment_echo(&echo.content))
248+ .map(move |echo| (room_id.clone(), echo.transaction_id))
249+ })
250+ .collect()
251+}
252+
253+fn upload_update(uploads: &mut HashSet<Upload>, update: SendQueueUpdate) -> Option<Event> {
254+ let room_id = update.room_id;
255+ let (transaction_id, outcome) = match update.update {
256+ RoomSendQueueUpdate::NewLocalEvent(echo) => {
257+ if is_attachment_echo(&echo.content) {
258+ uploads.insert((room_id, echo.transaction_id));
259+ }
260+ return None;
261+ }
262+ RoomSendQueueUpdate::SendError {
204263 transaction_id,
205264 error,
206265 is_recoverable,
207- } = update.update
208- else {
209- continue;
210- };
211- let Some(room) = client.get_room(&update.room_id) else {
212- continue;
213- };
214- let is_attachment = room
215- .send_queue()
216- .subscribe()
217- .await
218- .is_ok_and(|(echoes, _)| {
219- echoes.iter().any(|echo| {
220- echo.transaction_id == transaction_id && is_attachment_echo(&echo.content)
221- })
266+ } => {
267+ let upload = (room_id, transaction_id);
268+ if !uploads.contains(&upload) {
269+ return None;
270+ }
271+ let (room_id, transaction_id) = upload;
272+ return Some(Event::MediaUploadFailed {
273+ room_id,
274+ transaction_id,
275+ kind: classify_sdk_error(&error),
276+ will_retry: is_recoverable,
222277 });
223- if !is_attachment {
224- continue;
225278 }
226- let event = Event::MediaUploadFailed {
227- room_id: update.room_id,
279+ RoomSendQueueUpdate::SentEvent {
228280 transaction_id,
229- kind: classify_sdk_error(&error),
230- recoverable: is_recoverable,
231- };
232- if events.send(event).await.is_err() {
233- return;
281+ event_id,
282+ } => (transaction_id, UploadOutcome::Sent { event_id }),
283+ RoomSendQueueUpdate::CancelledLocalEvent { transaction_id } => {
284+ (transaction_id, UploadOutcome::Cancelled)
234285 }
286+ _ => return None,
287+ };
288+ let upload = (room_id, transaction_id);
289+ if !uploads.remove(&upload) {
290+ return None;
235291 }
292+ let (room_id, transaction_id) = upload;
293+ Some(Event::MediaUploadFinished {
294+ room_id,
295+ transaction_id,
296+ outcome,
297+ })
298+}
299+
300+fn finished_uploads(uploads: Vec<Upload>, outcome: UploadOutcome) -> Vec<Event> {
301+ uploads
302+ .into_iter()
303+ .map(|(room_id, transaction_id)| Event::MediaUploadFinished {
304+ room_id,
305+ transaction_id,
306+ outcome: outcome.clone(),
307+ })
308+ .collect()
236309 }
237310
238311 fn is_attachment_echo(content: &LocalEchoContent) -> bool {
@@ -308,12 +381,11 @@ pub(super) async fn notification_settings(client: Client, events: EventSender) {
308381 let settings = client.notification_settings().await;
309382 let mut changes = settings.subscribe_to_changes();
310383 while let Ok(()) | Err(RecvError::Lagged(_)) = changes.recv().await {
311- if events
312- .send(Event::NotificationSettingsChanged)
313- .await
314- .is_err()
315- {
316- return;
384+ // room summaries carry the effective mode, and push rule changes never touch room info
385+ for event in [Event::NotificationSettingsChanged, Event::RoomsChanged] {
386+ if events.send(event).await.is_err() {
387+ return;
388+ }
317389 }
318390 }
319391 }
Modified file: src/lib.rs+2 additions−2 deletions
@@ -38,8 +38,8 @@ pub use model::{
3838 RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary, RoomVisibility, SendState,
3939 Sender, SpaceChild, Termination, ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff,
4040 TimelineDiffError, TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState,
41- UploadDetails, UploadThumbnail, UtdCause, VerificationAction, VerificationFailure,
42- VerificationState, VerificationTarget,
41+ UploadDetails, UploadOutcome, UploadThumbnail, UtdCause, VerificationAction,
42+ VerificationFailure, VerificationState, VerificationTarget,
4343 };
4444 pub use transport::{ClientHandle, EventStream, Pending, SpaceHierarchy};
4545 pub use uiaa::{
Modified file: src/model.rs+29 additions−1 deletions
@@ -658,6 +658,12 @@ pub enum Event {
658658 timeline_id: TimelineId,
659659 state: HistoryState,
660660 },
661+ /// the timeline was closed, or its room was left, and nothing more comes for it
662+ /// until another `TimelineReset`
663+ TimelineClosed {
664+ timeline_id: TimelineId,
665+ },
666+ /// a room that stops being watched says so with an empty `users`
661667 TypingChanged(TypingState),
662668 MediaUploadProgress {
663669 room_id: OwnedRoomId,
@@ -666,11 +672,19 @@ pub enum Event {
666672 total_bytes: u64,
667673 complete: bool,
668674 },
675+ /// not the end of it. `will_retry` means the send queue has another go by itself,
676+ /// otherwise it waits for `retry_send` or `cancel_send`
669677 MediaUploadFailed {
670678 room_id: OwnedRoomId,
671679 transaction_id: OwnedTransactionId,
672680 kind: crate::ErrorKind,
673- recoverable: bool,
681+ will_retry: bool,
682+ },
683+ /// every upload ends with exactly one of these
684+ MediaUploadFinished {
685+ room_id: OwnedRoomId,
686+ transaction_id: OwnedTransactionId,
687+ outcome: UploadOutcome,
674688 },
675689 EncryptionChanged(EncryptionState),
676690 VerificationChanged(VerificationState),
@@ -682,6 +696,20 @@ pub enum Event {
682696 Stopped,
683697 }
684698
699+#[derive(Clone, Debug, Eq, PartialEq)]
700+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
701+#[non_exhaustive]
702+pub enum UploadOutcome {
703+ Sent {
704+ event_id: OwnedEventId,
705+ },
706+ Cancelled,
707+ /// its room was forgotten, and the queue with it
708+ RoomForgotten,
709+ /// the sdk's update channel overflowed while it ended, so whether it was sent is unknown
710+ Missed,
711+}
712+
685713 /// why `Engine::run` came back, when nothing went wrong
686714 #[derive(Clone, Debug, Eq, PartialEq)]
687715 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
Modified file: tests/engine.rs+4 additions
@@ -180,6 +180,10 @@ async fn keeps_open_room_timelines_independent() {
180180 .expect("both retained rooms did not update");
181181
182182 handle.close_room(first_id.to_owned()).await.unwrap();
183+ receive_until(&mut events, |event| {
184+ matches!(event, Event::TimelineClosed { timeline_id } if timeline_id.room_id() == first_id)
185+ })
186+ .await;
183187 server
184188 .sync_room(
185189 &sync_client,
Modified file: tests/media.rs+9 additions−1 deletions
@@ -4,7 +4,7 @@ use std::{sync::Arc, time::Duration};
44
55 use macltok::{
66 AttachmentUpload, ClientHandle, Engine, EngineConfig, EngineError, ErrorKind, Event,
7- EventStream, MediaId, MediaSize, UploadDetails, UploadThumbnail,
7+ EventStream, MediaId, MediaSize, UploadDetails, UploadOutcome, UploadThumbnail,
88 };
99 use matrix_sdk::{
1010 media::{MediaFormat, MediaRequestParameters},
@@ -600,6 +600,14 @@ async fn voice_upload_emits_voice_details_and_correlated_progress() {
600600 total_bytes,
601601 complete: true,
602602 } if room_id == room_id!("!media:localhost") && seen == transaction_id && uploaded_bytes == data.len() as u64 && total_bytes == data.len() as u64));
603+ support::receive_until(&mut harness.events, |event| {
604+ matches!(event, Event::MediaUploadFinished {
605+ transaction_id: seen,
606+ outcome: UploadOutcome::Sent { event_id },
607+ ..
608+ } if seen == &transaction_id && event_id == event_id!("$voice-upload:localhost"))
609+ })
610+ .await;
603611
604612 let content = uploaded_content(&harness.server).await;
605613 assert_eq!(content["msgtype"], "m.audio");