atagen

macltok

public
Star0
Watch0

Commit 7c26fad34822

tarball

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

5 files changed+215-99
engine: give every reported upload an end, and the device to log back in as

Progress went out straight from the room forwarders, so an upload the tracker lost to a lagged client-wide channel could get progress and never a MediaUploadFinished. Progress now passes through the tracker, which registers the id, drops ticks for uploads it has already ended, and is the only task emitting upload events, so nothing about an upload can reach the stream after its end. SessionAccessRejected carries the device on a soft logout, so a stream-only consumer needn't read it from the record.
Modified file: docs/design-decisions.md+7 additions
@@ -152,6 +152,13 @@ missed when that channel overflowed at the wrong moment, since then there's no
152152 knowing which. a failure isn't an ending, a wedged upload can still be retried.
153153 uploads don't belong to a room's actor, so closing the room leaves them be
154154
155+the forwarders hand their progress to that watcher rather than the stream, so
156+it's the only thing speaking about uploads. anything that ever showed progress
157+or a failure is followed until it gets exactly one `MediaUploadFinished`, and
158+nothing about it goes out after. it remembers what has ended for as long as it
159+runs, since a forwarder can still be sitting on progress for an upload that's
160+already sent and nothing says when that backlog is gone
161+
155162 ## sessions and secrets
156163
157164 `Session::login_password`, `begin_oauth` and `restore` share one client builder
Modified file: src/engine/mod.rs+8 additions−2 deletions
@@ -219,6 +219,7 @@ struct Queues {
219219 notifications: mpsc::Receiver<Job<NotificationCommand>>,
220220 encryption: mpsc::Receiver<Job<EncryptionCommand>>,
221221 session_changes: broadcast::Receiver<SessionChange>,
222+ upload_progress: mpsc::Receiver<watchers::UploadProgress>,
222223 }
223224
224225 /// requests other devices send us, handed from the sdk's event handler to the encryption worker
@@ -244,6 +245,7 @@ pub struct Engine {
244245 queues: Option<Queues>,
245246 /// one progress forwarder per room that has uploaded, and whether it's subscribed yet
246247 uploads: HashMap<OwnedRoomId, watch::Receiver<bool>>,
248+ upload_progress: mpsc::Sender<watchers::UploadProgress>,
247249 /// the client can outlive us, so the handler has to come off it when we stop
248250 verification_handler: Option<matrix_sdk::event_handler::EventHandlerHandle>,
249251 stop: mpsc::UnboundedSender<Termination>,
@@ -288,6 +290,7 @@ impl Engine {
288290 client.send_queue().enable_upload_progress(true);
289291 let (note_sender, notes) = mpsc::unbounded_channel();
290292 let (stop, stops) = mpsc::unbounded_channel();
293+ let (upload_progress, upload_progress_queue) = mpsc::channel(config.event_capacity);
291294 let (mutations, mutation_queue) = mpsc::channel(config.command_capacity);
292295 let (notifications, notification_queue) = mpsc::channel(config.command_capacity);
293296 let (encryption, encryption_queue) = mpsc::channel(config.command_capacity);
@@ -313,7 +316,9 @@ impl Engine {
313316 notifications: notification_queue,
314317 encryption: encryption_queue,
315318 session_changes,
319+ upload_progress: upload_progress_queue,
316320 }),
321+ upload_progress,
317322 uploads: HashMap::new(),
318323 verification_handler: None,
319324 stop,
@@ -401,10 +406,11 @@ impl Engine {
401406 let events = || self.port.event_sender();
402407 let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 11] = [
403408 (
404- "upload watcher",
409+ "upload tracker",
405410 Box::pin(watchers::uploads(
406411 client.clone(),
407412 client.send_queue().subscribe(),
413+ queues.upload_progress,
408414 events(),
409415 )),
410416 ),
@@ -648,7 +654,7 @@ impl Engine {
648654 let (subscribed, ready) = watch::channel(false);
649655 self.tasks.spawn(
650656 TaskContext::Command("upload watcher"),
651- watchers::room_uploads(room.clone(), self.port.event_sender(), subscribed),
657+ watchers::room_uploads(room.clone(), self.upload_progress.clone(), subscribed),
652658 );
653659 self.uploads
654660 .insert(room.room_id().to_owned(), ready.clone());
Modified file: src/engine/watchers.rs+190 additions−92 deletions
@@ -42,15 +42,15 @@ pub(super) async fn session(
4242 let event = match changes.recv().await {
4343 Ok(SessionChange::TokensRefreshed) => Event::SessionTokensChanged,
4444 Ok(SessionChange::UnknownToken(data)) => {
45+ let soft_logout = data.soft_logout.then_some(device_id);
4546 let _ = events
4647 .send(Event::SessionAccessRejected {
47- soft_logout: data.soft_logout,
48+ soft_logout: soft_logout.clone(),
4849 })
4950 .await;
50- let _ = stop.send(if data.soft_logout {
51- Termination::SoftLoggedOut { device_id }
52- } else {
53- Termination::AccessRevoked
51+ let _ = stop.send(match soft_logout {
52+ Some(device_id) => Termination::SoftLoggedOut { device_id },
53+ None => Termination::AccessRevoked,
5454 });
5555 return;
5656 }
@@ -146,7 +146,11 @@ pub(super) async fn send_queue_errors(
146146 }
147147
148148 /// upload progress only goes out on the room's own channel, never the client-wide one
149-pub(super) async fn room_uploads(room: Room, events: EventSender, ready: watch::Sender<bool>) {
149+pub(super) async fn room_uploads(
150+ room: Room,
151+ progress: mpsc::Sender<UploadProgress>,
152+ ready: watch::Sender<bool>,
153+) {
150154 let room_id = room.room_id().to_owned();
151155 let mut updates = match room.send_queue().subscribe().await {
152156 Ok((_, updates)) => updates,
@@ -166,66 +170,68 @@ pub(super) async fn room_uploads(room: Room, events: EventSender, ready: watch::
166170 let RoomSendQueueUpdate::MediaUpload {
167171 related_to,
168172 file,
169- progress,
173+ progress: done,
170174 ..
171175 } = update
172176 else {
173177 continue;
174178 };
175- let event = Event::MediaUploadProgress {
176- room_id: room_id.clone(),
177- transaction_id: related_to,
178- uploaded_bytes: progress.current as u64,
179- total_bytes: progress.total as u64,
180- complete: file.is_some() && progress.current == progress.total,
179+ let tick = UploadProgress {
180+ upload: (room_id.clone(), related_to),
181+ uploaded_bytes: done.current as u64,
182+ total_bytes: done.total as u64,
183+ complete: file.is_some() && done.current == done.total,
181184 };
182- if events.send(event).await.is_err() {
185+ if progress.send(tick).await.is_err() {
183186 return;
184187 }
185188 }
186189 }
187190
188-/// progress only goes out on each room's own channel, everything else reaches this
189-/// one, which exists before any upload does
191+pub(super) struct UploadProgress {
192+ upload: Upload,
193+ uploaded_bytes: u64,
194+ total_bytes: u64,
195+ complete: bool,
196+}
197+
198+/// the one place upload events leave from, so an upload is never reported after it
199+/// ended. progress comes in from the room forwarders, the rest from the client-wide
200+/// channel, which exists before any upload does
190201 pub(super) async fn uploads(
191202 client: Client,
192203 mut updates: tokio::sync::broadcast::Receiver<SendQueueUpdate>,
204+ mut progress: mpsc::Receiver<UploadProgress>,
193205 events: EventSender,
194206 ) {
195207 let (_, mut rooms) = client.rooms_stream();
196- let mut uploads = attachment_echoes(&client).await;
208+ let mut tracker = UploadTracker {
209+ live: attachment_echoes(&client).await,
210+ ended: HashSet::new(),
211+ };
197212 loop {
198- let finished = tokio::select! {
213+ let reports = tokio::select! {
199214 update = updates.recv() => match update {
200- Ok(update) => match upload_update(&mut uploads, update) {
201- Some(event) => vec![event],
202- None => continue,
203- },
215+ Ok(update) => tracker.update(update).into_iter().collect(),
204216 Err(RecvError::Lagged(skipped)) => {
205217 tracing::warn!(skipped, "upload watcher lagged");
206218 let current = attachment_echoes(&client).await;
207- let missed = uploads.difference(&current).cloned().collect::<Vec<_>>();
208- uploads = current;
209- finished_uploads(missed, UploadOutcome::Missed)
219+ tracker.resnapshot(current)
210220 }
211221 Err(RecvError::Closed) => return,
212222 },
223+ Some(tick) = progress.recv() => tracker.progress(tick).into_iter().collect(),
213224 update = rooms.next() => {
214225 if update.is_none() {
215226 return;
216227 }
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)
228+ tracker.finish_where(
229+ |(room_id, _)| client.get_room(room_id).is_none(),
230+ &UploadOutcome::RoomForgotten,
231+ )
226232 }
227233 };
228- for event in finished {
234+ for event in reports {
229235 if events.send(event).await.is_err() {
230236 return;
231237 }
@@ -235,75 +241,119 @@ pub(super) async fn uploads(
235241
236242 type Upload = (OwnedRoomId, OwnedTransactionId);
237243
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()
244+/// everything reported gets exactly one `MediaUploadFinished`. `ended` is kept for good,
245+/// since a room forwarder can still be holding progress for an upload the client-wide
246+/// channel has already seen sent
247+struct UploadTracker {
248+ live: HashSet<Upload>,
249+ ended: HashSet<Upload>,
251250 }
252251
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 {
263- transaction_id,
264- error,
265- is_recoverable,
266- } => {
267- let upload = (room_id, transaction_id);
268- if !uploads.contains(&upload) {
252+impl UploadTracker {
253+ fn update(&mut self, update: SendQueueUpdate) -> Option<Event> {
254+ let room_id = update.room_id;
255+ let (transaction_id, outcome) = match update.update {
256+ RoomSendQueueUpdate::NewLocalEvent(echo) => {
257+ let upload = (room_id, echo.transaction_id);
258+ if is_attachment_echo(&echo.content) && !self.ended.contains(&upload) {
259+ self.live.insert(upload);
260+ }
269261 return None;
270262 }
271- let (room_id, transaction_id) = upload;
272- return Some(Event::MediaUploadFailed {
273- room_id,
263+ RoomSendQueueUpdate::SendError {
274264 transaction_id,
275- kind: classify_sdk_error(&error),
276- will_retry: is_recoverable,
277- });
265+ error,
266+ is_recoverable,
267+ } => {
268+ let upload = (room_id, transaction_id);
269+ if !self.live.contains(&upload) {
270+ return None;
271+ }
272+ let (room_id, transaction_id) = upload;
273+ return Some(Event::MediaUploadFailed {
274+ room_id,
275+ transaction_id,
276+ kind: classify_sdk_error(&error),
277+ will_retry: is_recoverable,
278+ });
279+ }
280+ RoomSendQueueUpdate::SentEvent {
281+ transaction_id,
282+ event_id,
283+ } => (transaction_id, UploadOutcome::Sent { event_id }),
284+ RoomSendQueueUpdate::CancelledLocalEvent { transaction_id } => {
285+ (transaction_id, UploadOutcome::Cancelled)
286+ }
287+ _ => return None,
288+ };
289+ self.finish((room_id, transaction_id), outcome)
290+ }
291+
292+ fn progress(&mut self, tick: UploadProgress) -> Option<Event> {
293+ if self.ended.contains(&tick.upload) {
294+ return None;
278295 }
279- RoomSendQueueUpdate::SentEvent {
296+ self.live.insert(tick.upload.clone());
297+ let (room_id, transaction_id) = tick.upload;
298+ Some(Event::MediaUploadProgress {
299+ room_id,
280300 transaction_id,
281- event_id,
282- } => (transaction_id, UploadOutcome::Sent { event_id }),
283- RoomSendQueueUpdate::CancelledLocalEvent { transaction_id } => {
284- (transaction_id, UploadOutcome::Cancelled)
301+ uploaded_bytes: tick.uploaded_bytes,
302+ total_bytes: tick.total_bytes,
303+ complete: tick.complete,
304+ })
305+ }
306+
307+ fn resnapshot(&mut self, current: HashSet<Upload>) -> Vec<Event> {
308+ let reports = self.finish_where(|upload| !current.contains(upload), &UploadOutcome::Missed);
309+ let ended = &self.ended;
310+ self.live
311+ .extend(current.into_iter().filter(|upload| !ended.contains(upload)));
312+ reports
313+ }
314+
315+ fn finish_where(
316+ &mut self,
317+ ends: impl Fn(&Upload) -> bool,
318+ outcome: &UploadOutcome,
319+ ) -> Vec<Event> {
320+ let ending = self
321+ .live
322+ .iter()
323+ .filter(|upload| ends(upload))
324+ .cloned()
325+ .collect::<Vec<_>>();
326+ ending
327+ .into_iter()
328+ .filter_map(|upload| self.finish(upload, outcome.clone()))
329+ .collect()
330+ }
331+
332+ fn finish(&mut self, upload: Upload, outcome: UploadOutcome) -> Option<Event> {
333+ if !self.live.remove(&upload) {
334+ return None;
285335 }
286- _ => return None,
287- };
288- let upload = (room_id, transaction_id);
289- if !uploads.remove(&upload) {
290- return None;
336+ self.ended.insert(upload.clone());
337+ let (room_id, transaction_id) = upload;
338+ Some(Event::MediaUploadFinished {
339+ room_id,
340+ transaction_id,
341+ outcome,
342+ })
291343 }
292- let (room_id, transaction_id) = upload;
293- Some(Event::MediaUploadFinished {
294- room_id,
295- transaction_id,
296- outcome,
297- })
298344 }
299345
300-fn finished_uploads(uploads: Vec<Upload>, outcome: UploadOutcome) -> Vec<Event> {
301- uploads
346+async fn attachment_echoes(client: &Client) -> HashSet<Upload> {
347+ let Ok(echoes) = client.send_queue().local_echoes().await else {
348+ return HashSet::new();
349+ };
350+ echoes
302351 .into_iter()
303- .map(|(room_id, transaction_id)| Event::MediaUploadFinished {
304- room_id,
305- transaction_id,
306- outcome: outcome.clone(),
352+ .flat_map(|(room_id, echoes)| {
353+ echoes
354+ .into_iter()
355+ .filter(|echo| is_attachment_echo(&echo.content))
356+ .map(move |echo| (room_id.clone(), echo.transaction_id))
307357 })
308358 .collect()
309359 }
@@ -389,3 +439,51 @@ pub(super) async fn notification_settings(client: Client, events: EventSender) {
389439 }
390440 }
391441 }
442+
443+#[cfg(test)]
444+mod tests {
445+ use matrix_sdk::send_queue::{RoomSendQueueUpdate, SendQueueUpdate};
446+ use ruma::{OwnedTransactionId, event_id, owned_room_id};
447+
448+ use super::{HashSet, UploadProgress, UploadTracker};
449+ use crate::{Event, UploadOutcome};
450+
451+ #[test]
452+ fn an_upload_only_progress_saw_still_ends_once() {
453+ let upload = (
454+ owned_room_id!("!room:localhost"),
455+ OwnedTransactionId::from("upload"),
456+ );
457+ let tick = || UploadProgress {
458+ upload: upload.clone(),
459+ uploaded_bytes: 1,
460+ total_bytes: 2,
461+ complete: false,
462+ };
463+ let mut tracker = UploadTracker {
464+ live: HashSet::new(),
465+ ended: HashSet::new(),
466+ };
467+ assert!(tracker.progress(tick()).is_some());
468+
469+ let missed = tracker.resnapshot(HashSet::new());
470+ assert!(matches!(
471+ missed.as_slice(),
472+ [Event::MediaUploadFinished {
473+ outcome: UploadOutcome::Missed,
474+ ..
475+ }]
476+ ));
477+
478+ assert!(tracker.progress(tick()).is_none());
479+ let sent = SendQueueUpdate {
480+ room_id: upload.0.clone(),
481+ update: RoomSendQueueUpdate::SentEvent {
482+ transaction_id: upload.1.clone(),
483+ event_id: event_id!("$sent:localhost").to_owned(),
484+ },
485+ };
486+ assert!(tracker.update(sent).is_none());
487+ assert!(tracker.resnapshot(HashSet::from([upload])).is_empty());
488+ }
489+}
Modified file: src/model.rs+4 additions−4 deletions
@@ -637,11 +637,11 @@ pub enum Event {
637637 /// Refresh tokens changed. Persist a fresh `ClientHandle::session_record()`
638638 /// atomically; a record captured before this event may no longer restore.
639639 SessionTokensChanged,
640- /// the server stopped honouring our token and the engine stops. on a soft
641- /// logout keep the store and log in again with the same device, otherwise
642- /// the record and the store are both dead
640+ /// the server stopped honouring our token and the engine stops. a soft logout
641+ /// names the device to log in again as, keeping the store. otherwise the record
642+ /// and the store are both dead
643643 SessionAccessRejected {
644- soft_logout: bool,
644+ soft_logout: Option<OwnedDeviceId>,
645645 },
646646 NotificationSettingsChanged,
647647 VerificationTargetChanged(VerificationTarget),
Modified file: tests/state.rs+6 additions−1 deletions
@@ -90,7 +90,12 @@ async fn soft_logout_says_so_and_stops_the_engine() {
9090 .unwrap();
9191 let running = tokio::spawn(engine.run());
9292 support::receive_until(&mut events, |event| {
93- matches!(event, Event::SessionAccessRejected { soft_logout: true })
93+ matches!(
94+ event,
95+ Event::SessionAccessRejected {
96+ soft_logout: Some(_)
97+ }
98+ )
9499 })
95100 .await;
96101 support::receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;