atagen

macltok

public
Star0
Watch0
0e081dee5e28debddd20b865cc576862c03a2a66
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/engine/watchers.rsRust705 lines24.4 KB
1
use std::{
2
    collections::{HashMap, HashSet, VecDeque},
3
    sync::Arc,
4
    time::Duration,
5
};
6
7
use futures_util::StreamExt;
8
use matrix_sdk::{
9
    Client, Room,
10
    event_handler::EventHandlerHandle,
11
    ruma::events::{AnyMessageLikeEventContent, room::message::MessageType},
12
    send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendQueueUpdate},
13
    serde_helpers::extract_thread_root,
14
};
15
use matrix_sdk_base::RoomInfoNotableUpdateReasons;
16
use matrix_sdk_ui::sync_service::{State as SyncState, SyncService};
17
use ruma::{
18
    OwnedEventId, OwnedRoomId, OwnedTransactionId,
19
    events::{
20
        AnyGlobalAccountDataEvent, AnyRoomAccountDataEvent, AnySyncStateEvent,
21
        AnySyncTimelineEvent, receipt::SyncReceiptEvent,
22
    },
23
    serde::Raw,
24
    time::Instant,
25
};
26
use tokio::sync::{
27
    Notify,
28
    broadcast::error::{RecvError, TryRecvError},
29
    mpsc, watch,
30
};
31
32
use super::executor::TaskSet;
33
use crate::{
34
    ConnectionState, Event, UploadOutcome,
35
    encryption::encryption_state,
36
    error::classify_sdk_error,
37
    normalise::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK, recount_thread_unreads},
38
    space_order,
39
    transport::EventSender,
40
};
41
42
const SYNC_RESTART_MIN: Duration = Duration::from_secs(1);
43
const SYNC_RESTART_MAX: Duration = Duration::from_secs(60);
44
const SEND_QUEUE_RETRY: Duration = Duration::from_secs(5);
45
const SYNC_STABLE: Duration = Duration::from_secs(30);
46
47
pub(super) async fn room_subscriptions(
48
    sync: Arc<SyncService>,
49
    mut rooms: watch::Receiver<Vec<ruma::OwnedRoomId>>,
50
) {
51
    while rooms.changed().await.is_ok() {
52
        let room_ids = rooms.borrow_and_update().clone();
53
        let room_ids = room_ids.iter().map(AsRef::as_ref).collect::<Vec<_>>();
54
        sync.room_list_service().subscribe_to_rooms(&room_ids).await;
55
    }
56
}
57
58
/// `states` must be taken before sync starts, a late subscriber never hears the transition it missed
59
pub(super) async fn connection(
60
    mut states: impl futures_util::Stream<Item = SyncState> + Unpin,
61
    sync: Arc<SyncService>,
62
    client: Client,
63
    events: EventSender,
64
    connection: watch::Sender<ConnectionState>,
65
) {
66
    let mut backoff = SYNC_RESTART_MIN;
67
    let mut online_since: Option<Instant> = None;
68
    while let Some(state) = states.next().await {
69
        // only a run that lasted counts as recovered, or a server that answers /versions
70
        // but refuses sync would have us bounce between offline and online forever
71
        let stable = online_since
72
            .take()
73
            .map(|since| since.elapsed() >= SYNC_STABLE);
74
        if stable == Some(true) {
75
            backoff = SYNC_RESTART_MIN;
76
        }
77
        let (state, restart) = match state {
78
            // only ever us stopping it for a restart, nothing a client needs to hear
79
            SyncState::Idle => continue,
80
            SyncState::Running => {
81
                online_since = Some(Instant::now());
82
                // the sdk parks a room queue on any send error and never unparks it
83
                client.send_queue().set_enabled(true).await;
84
                (ConnectionState::Online, false)
85
            }
86
            SyncState::Offline => (ConnectionState::Offline, stable == Some(false)),
87
            SyncState::Terminated => (ConnectionState::Offline, true),
88
            SyncState::Error(error) => {
89
                tracing::warn!(%error, "sync service reported an error");
90
                (ConnectionState::Offline, true)
91
            }
92
        };
93
        if *connection.borrow() != state {
94
            connection.send_replace(state);
95
            if events.send(Event::ConnectionChanged(state)).await.is_err() {
96
                return;
97
            }
98
        }
99
        if restart {
100
            sync.stop().await;
101
            matrix_sdk::sleep::sleep(backoff).await;
102
            backoff = (backoff * 2).min(SYNC_RESTART_MAX);
103
            sync.start().await;
104
        }
105
    }
106
}
107
108
/// a recoverable failure while we're online is a hiccup, so give the room another go
109
pub(super) async fn send_queue_errors(
110
    client: Client,
111
    mut errors: tokio::sync::broadcast::Receiver<matrix_sdk::send_queue::SendQueueRoomError>,
112
    online: watch::Receiver<ConnectionState>,
113
) {
114
    let mut retries = TaskSet::new();
115
    loop {
116
        let error = tokio::select! {
117
            error = errors.recv() => error,
118
            Some(_) = retries.join_next(), if !retries.is_empty() => continue,
119
        };
120
        match error {
121
            Ok(error) if error.is_recoverable => {
122
                let client = client.clone();
123
                let online = online.clone();
124
                retries.spawn(async move {
125
                    matrix_sdk::sleep::sleep(SEND_QUEUE_RETRY).await;
126
                    if *online.borrow() == ConnectionState::Online
127
                        && let Some(room) = client.get_room(&error.room_id)
128
                    {
129
                        room.send_queue().set_enabled(true);
130
                    }
131
                });
132
            }
133
            Ok(_) | Err(RecvError::Lagged(_)) => {}
134
            Err(RecvError::Closed) => return,
135
        }
136
    }
137
}
138
139
/// the one place upload events leave from, so an upload is never reported after it
140
/// ended. the client-wide channel exists before any upload does. it holds 32 updates and
141
/// progress ticks share it, so this keeps draining while the event stream is full
142
pub(super) async fn uploads(
143
    client: Client,
144
    mut updates: tokio::sync::broadcast::Receiver<SendQueueUpdate>,
145
    events: EventSender,
146
) {
147
    let (_, mut rooms) = client.rooms_stream();
148
    let mut tracker = UploadTracker::new(attachment_echoes(&client).await);
149
    let mut pending = VecDeque::new();
150
    loop {
151
        let reports: Vec<Event> = tokio::select! {
152
            biased;
153
            update = updates.recv() => match update {
154
                Ok(update) => tracker.update(update).into_iter().collect(),
155
                Err(RecvError::Lagged(skipped)) => {
156
                    tracing::warn!(skipped, "upload tracker lagged");
157
                    tracker.resnapshot(attachment_echoes(&client).await)
158
                }
159
                Err(RecvError::Closed) => return,
160
            },
161
            update = rooms.next() => {
162
                if update.is_none() {
163
                    return;
164
                }
165
                tracker.finish_where(
166
                    |(room_id, _)| client.get_room(room_id).is_none(),
167
                    &UploadOutcome::RoomForgotten,
168
                )
169
            }
170
            permit = events.reserve(), if !pending.is_empty() => {
171
                let Ok(permit) = permit else { return };
172
                permit.send(pending.pop_front().expect("pending was checked"));
173
                continue;
174
            }
175
        };
176
        for event in reports {
177
            queue_upload_event(&mut pending, event);
178
        }
179
    }
180
}
181
182
/// a progress tick replaces a waiting one for the same upload, unless something else about
183
/// that upload is queued after it
184
fn queue_upload_event(pending: &mut VecDeque<Event>, event: Event) {
185
    if matches!(event, Event::MediaUploadProgress { .. })
186
        && let Some(waiting) = pending
187
            .iter_mut()
188
            .rev()
189
            .find(|waiting| upload_of(waiting) == upload_of(&event))
190
        && matches!(waiting, Event::MediaUploadProgress { .. })
191
    {
192
        *waiting = event;
193
        return;
194
    }
195
    pending.push_back(event);
196
}
197
198
fn upload_of(event: &Event) -> Option<(&OwnedRoomId, &OwnedTransactionId)> {
199
    match event {
200
        Event::MediaUploadProgress {
201
            room_id,
202
            transaction_id,
203
            ..
204
        }
205
        | Event::MediaUploadFailed {
206
            room_id,
207
            transaction_id,
208
            ..
209
        }
210
        | Event::MediaUploadFinished {
211
            room_id,
212
            transaction_id,
213
            ..
214
        } => Some((room_id, transaction_id)),
215
        _ => None,
216
    }
217
}
218
219
type Upload = (OwnedRoomId, OwnedTransactionId);
220
221
/// the sdk sends progress ticks from a detached task, so a tick can land after its
222
/// upload's end. it trails by one task hop, so a short memory of ends covers it
223
const REMEMBERED_ENDS: usize = 256;
224
225
/// everything reported gets exactly one `MediaUploadFinished` and nothing after it
226
struct UploadTracker {
227
    live: HashSet<Upload>,
228
    ended: HashSet<Upload>,
229
    ended_order: VecDeque<Upload>,
230
}
231
232
impl UploadTracker {
233
    fn new(live: HashSet<Upload>) -> Self {
234
        Self {
235
            live,
236
            ended: HashSet::new(),
237
            ended_order: VecDeque::new(),
238
        }
239
    }
240
241
    fn update(&mut self, update: SendQueueUpdate) -> Option<Event> {
242
        let room_id = update.room_id;
243
        let (transaction_id, outcome) = match update.update {
244
            RoomSendQueueUpdate::NewLocalEvent(echo) => {
245
                let upload = (room_id, echo.transaction_id);
246
                if is_attachment_echo(&echo.content) && !self.ended.contains(&upload) {
247
                    self.live.insert(upload);
248
                }
249
                return None;
250
            }
251
            RoomSendQueueUpdate::MediaUpload {
252
                related_to,
253
                file,
254
                progress,
255
                ..
256
            } => {
257
                let upload = (room_id, related_to);
258
                if self.ended.contains(&upload) {
259
                    return None;
260
                }
261
                self.live.insert(upload.clone());
262
                let (room_id, transaction_id) = upload;
263
                return Some(Event::MediaUploadProgress {
264
                    room_id,
265
                    transaction_id,
266
                    uploaded_bytes: progress.current as u64,
267
                    total_bytes: progress.total as u64,
268
                    complete: file.is_some() && progress.current == progress.total,
269
                });
270
            }
271
            RoomSendQueueUpdate::SendError {
272
                transaction_id,
273
                error,
274
                is_recoverable,
275
            } => {
276
                let upload = (room_id, transaction_id);
277
                if !self.live.contains(&upload) {
278
                    return None;
279
                }
280
                let (room_id, transaction_id) = upload;
281
                return Some(Event::MediaUploadFailed {
282
                    room_id,
283
                    transaction_id,
284
                    kind: classify_sdk_error(&error),
285
                    will_retry: is_recoverable,
286
                });
287
            }
288
            RoomSendQueueUpdate::SentEvent {
289
                transaction_id,
290
                event_id,
291
            } => (transaction_id, UploadOutcome::Sent { event_id }),
292
            RoomSendQueueUpdate::CancelledLocalEvent { transaction_id } => {
293
                (transaction_id, UploadOutcome::Cancelled)
294
            }
295
            _ => return None,
296
        };
297
        self.finish((room_id, transaction_id), outcome)
298
    }
299
300
    fn resnapshot(&mut self, current: HashSet<Upload>) -> Vec<Event> {
301
        let reports = self.finish_where(|upload| !current.contains(upload), &UploadOutcome::Missed);
302
        let ended = &self.ended;
303
        self.live
304
            .extend(current.into_iter().filter(|upload| !ended.contains(upload)));
305
        reports
306
    }
307
308
    fn finish_where(
309
        &mut self,
310
        ends: impl Fn(&Upload) -> bool,
311
        outcome: &UploadOutcome,
312
    ) -> Vec<Event> {
313
        let ending = self
314
            .live
315
            .iter()
316
            .filter(|upload| ends(upload))
317
            .cloned()
318
            .collect::<Vec<_>>();
319
        ending
320
            .into_iter()
321
            .filter_map(|upload| self.finish(upload, outcome.clone()))
322
            .collect()
323
    }
324
325
    /// an end for an upload we never saw is remembered too, its ticks may still be coming
326
    fn finish(&mut self, upload: Upload, outcome: UploadOutcome) -> Option<Event> {
327
        if self.ended.insert(upload.clone()) {
328
            self.ended_order.push_back(upload.clone());
329
            if self.ended_order.len() > REMEMBERED_ENDS
330
                && let Some(oldest) = self.ended_order.pop_front()
331
            {
332
                self.ended.remove(&oldest);
333
            }
334
        }
335
        if !self.live.remove(&upload) {
336
            return None;
337
        }
338
        let (room_id, transaction_id) = upload;
339
        Some(Event::MediaUploadFinished {
340
            room_id,
341
            transaction_id,
342
            outcome,
343
        })
344
    }
345
}
346
347
async fn attachment_echoes(client: &Client) -> HashSet<Upload> {
348
    let Ok(echoes) = client.send_queue().local_echoes().await else {
349
        return HashSet::new();
350
    };
351
    echoes
352
        .into_iter()
353
        .flat_map(|(room_id, echoes)| {
354
            echoes
355
                .into_iter()
356
                .filter(|echo| is_attachment_echo(&echo.content))
357
                .map(move |echo| (room_id.clone(), echo.transaction_id))
358
        })
359
        .collect()
360
}
361
362
fn is_attachment_echo(content: &LocalEchoContent) -> bool {
363
    let LocalEchoContent::Event {
364
        serialized_event, ..
365
    } = content
366
    else {
367
        return false;
368
    };
369
    matches!(
370
        serialized_event.deserialize(),
371
        Ok(AnyMessageLikeEventContent::RoomMessage(content))
372
            if matches!(
373
                content.msgtype,
374
                MessageType::Audio(_) | MessageType::File(_) | MessageType::Image(_) | MessageType::Video(_)
375
            )
376
    )
377
}
378
379
pub(super) async fn encryption(client: Client, events: EventSender) {
380
    let Some(device_id) = client.device_id().map(ToOwned::to_owned) else {
381
        tracing::warn!("encryption watcher stopped because the client has no device id");
382
        return;
383
    };
384
    let recovery = client.encryption().recovery();
385
    let backups = client.encryption().backups();
386
    let mut recovery_stream = recovery.state_stream();
387
    let mut backup_stream = backups.state_stream();
388
    let mut verification_stream = client.encryption().verification_state();
389
    let mut recovery_state = recovery.state();
390
    let mut backup_state = backups.state();
391
    let mut verification_state = verification_stream.get();
392
    loop {
393
        let state = encryption_state(
394
            device_id.clone(),
395
            recovery_state,
396
            verification_state,
397
            backup_state,
398
        );
399
        if events.send(Event::EncryptionChanged(state)).await.is_err() {
400
            return;
401
        }
402
        tokio::select! {
403
            Some(update) = recovery_stream.next() => recovery_state = update,
404
            Some(update) = verification_stream.next() => verification_state = update,
405
            Some(update) = backup_stream.next() => {
406
                backup_state = update.unwrap_or_else(|_| backups.state());
407
            }
408
            else => return,
409
        }
410
    }
411
}
412
413
/// space orders, pins and power levels all feed summaries without raising a notable room info update
414
pub(super) async fn rooms(client: Client, events: EventSender) {
415
    let (_, mut rooms) = client.rooms_stream();
416
    let mut info = client.room_info_notable_update_receiver();
417
    let changed = Arc::new(Notify::new());
418
    let handlers = [
419
        client.add_event_handler({
420
            let changed = changed.clone();
421
            move |event: Raw<AnyRoomAccountDataEvent>| {
422
                let changed = changed.clone();
423
                async move {
424
                    if event_type(&event).as_deref() == Some(space_order::EVENT_TYPE) {
425
                        changed.notify_one();
426
                    }
427
                }
428
            }
429
        }),
430
        client.add_event_handler({
431
            let changed = changed.clone();
432
            move |event: Raw<AnySyncStateEvent>| {
433
                let changed = changed.clone();
434
                async move {
435
                    if matches!(
436
                        event_type(&event).as_deref(),
437
                        Some("m.room.pinned_events" | "m.room.power_levels")
438
                    ) {
439
                        changed.notify_one();
440
                    }
441
                }
442
            }
443
        }),
444
    ];
445
    let (touches, mut touched) = mpsc::unbounded_channel();
446
    let thread_handlers = thread_handlers(&client, &touches);
447
    let mut timelines = client.event_cache().subscribe_to_room_generic_updates();
448
    'invalidate: loop {
449
        if events.send(Event::RoomsChanged).await.is_err() {
450
            break;
451
        }
452
        // thread events and threaded receipts leave room info alone, so neither reaches `info`
453
        let mut pending = Touched::default();
454
        loop {
455
            let mut invalidate = tokio::select! {
456
                update = rooms.next() => if update.is_none() { break 'invalidate } else { true },
457
                update = info.recv() => {
458
                    if matches!(update, Err(RecvError::Closed)) { break 'invalidate }
459
                    true
460
                }
461
                () = changed.notified() => true,
462
                Some((room_id, root)) = touched.recv() => {
463
                    pending.add(room_id, root);
464
                    false
465
                }
466
                update = timelines.recv() => match update {
467
                    Ok(update) => {
468
                        pending.add(update.room_id, None);
469
                        false
470
                    }
471
                    Err(RecvError::Lagged(_)) => true,
472
                    Err(RecvError::Closed) => break 'invalidate,
473
                },
474
            };
475
            // a sync's room info lands before the event cache has stored its events, so
476
            // give it a moment and take the whole sync as one invalidation
477
            matrix_sdk::sleep::sleep(SYNC_SETTLE).await;
478
            loop {
479
                match info.try_recv() {
480
                    Ok(_) | Err(TryRecvError::Lagged(_)) => invalidate = true,
481
                    Err(_) => break,
482
                }
483
            }
484
            while let Ok((room_id, root)) = touched.try_recv() {
485
                pending.add(room_id, root);
486
            }
487
            loop {
488
                match timelines.try_recv() {
489
                    Ok(update) => pending.add(update.room_id, None),
490
                    Err(TryRecvError::Lagged(_)) => invalidate = true,
491
                    Err(_) => break,
492
                }
493
            }
494
            for (room_id, roots) in pending.0.drain() {
495
                if let Some(room) = client.get_room(&room_id)
496
                    && recount_thread_unreads(&room, roots).await.is_some()
497
                {
498
                    invalidate = true;
499
                }
500
            }
501
            if invalidate {
502
                break;
503
            }
504
        }
505
    }
506
    for handler in handlers.into_iter().chain(thread_handlers) {
507
        client.remove_event_handler(handler);
508
    }
509
}
510
511
const SYNC_SETTLE: Duration = Duration::from_millis(100);
512
513
/// rooms to recount, with the threads a sync brought news of
514
#[derive(Default)]
515
struct Touched(HashMap<OwnedRoomId, HashSet<OwnedEventId>>);
516
517
impl Touched {
518
    fn add(&mut self, room_id: OwnedRoomId, root: Option<OwnedEventId>) {
519
        let roots = self.0.entry(room_id).or_default();
520
        roots.extend(root);
521
    }
522
}
523
524
/// a thread event names its thread before the event cache has it, and only the user's own
525
/// receipts move their counts
526
fn thread_handlers(
527
    client: &Client,
528
    touches: &mpsc::UnboundedSender<(OwnedRoomId, Option<OwnedEventId>)>,
529
) -> [EventHandlerHandle; 2] {
530
    let own_user_id = client.user_id().map(ToOwned::to_owned);
531
    [
532
        client.add_event_handler({
533
            let touches = touches.clone();
534
            move |event: Raw<AnySyncTimelineEvent>, room: Room| {
535
                if let Some(root) = extract_thread_root(&event) {
536
                    let _ = touches.send((room.room_id().to_owned(), Some(root)));
537
                }
538
                async {}
539
            }
540
        }),
541
        client.add_event_handler({
542
            let touches = touches.clone();
543
            move |event: SyncReceiptEvent, room: Room| {
544
                let own = own_user_id.as_ref().is_some_and(|own_user_id| {
545
                    event
546
                        .content
547
                        .0
548
                        .values()
549
                        .flat_map(|receipts| receipts.values())
550
                        .any(|users| users.contains_key(own_user_id))
551
                });
552
                if own {
553
                    let _ = touches.send((room.room_id().to_owned(), None));
554
                }
555
                async {}
556
            }
557
        }),
558
    ]
559
}
560
561
/// packs live in room state and account data, and leaving a room takes its packs with it
562
pub(super) async fn image_packs(client: Client, events: EventSender) {
563
    let changed = Arc::new(Notify::new());
564
    let handlers = [
565
        client.add_event_handler({
566
            let changed = changed.clone();
567
            move |event: Raw<AnySyncStateEvent>| {
568
                let changed = changed.clone();
569
                async move {
570
                    if event_type(&event).as_deref() == Some(ROOM_PACKS) {
571
                        changed.notify_one();
572
                    }
573
                }
574
            }
575
        }),
576
        client.add_event_handler({
577
            let changed = changed.clone();
578
            move |event: Raw<AnyGlobalAccountDataEvent>| {
579
                let changed = changed.clone();
580
                async move {
581
                    if matches!(
582
                        event_type(&event).as_deref(),
583
                        Some(USER_PACK | ENABLED_ROOMS)
584
                    ) {
585
                        changed.notify_one();
586
                    }
587
                }
588
            }
589
        }),
590
    ];
591
    let mut info = client.room_info_notable_update_receiver();
592
    loop {
593
        tokio::select! {
594
            () = changed.notified() => {}
595
            update = info.recv() => match update {
596
                Ok(update) if !update.reasons.contains(RoomInfoNotableUpdateReasons::MEMBERSHIP) => {
597
                    continue;
598
                }
599
                Ok(_) | Err(RecvError::Lagged(_)) => {}
600
                Err(RecvError::Closed) => break,
601
            },
602
        }
603
        if events.send(Event::ImagePacksChanged).await.is_err() {
604
            break;
605
        }
606
    }
607
    for handler in handlers {
608
        client.remove_event_handler(handler);
609
    }
610
}
611
612
fn event_type<T>(event: &Raw<T>) -> Option<String> {
613
    event.get_field("type").ok().flatten()
614
}
615
616
pub(super) async fn notification_settings(client: Client, events: EventSender) {
617
    let settings = client.notification_settings().await;
618
    let mut changes = settings.subscribe_to_changes();
619
    while let Ok(()) | Err(RecvError::Lagged(_)) = changes.recv().await {
620
        // room summaries carry the effective mode, and push rule changes never touch room info
621
        for event in [Event::NotificationSettingsChanged, Event::RoomsChanged] {
622
            if events.send(event).await.is_err() {
623
                return;
624
            }
625
        }
626
    }
627
}
628
629
#[cfg(test)]
630
mod tests {
631
    use matrix_sdk::send_queue::{AbstractProgress, RoomSendQueueUpdate, SendQueueUpdate};
632
    use ruma::{OwnedTransactionId, event_id, owned_room_id};
633
634
    use super::{HashSet, Upload, UploadTracker};
635
    use crate::{Event, UploadOutcome};
636
637
    fn upload() -> Upload {
638
        (
639
            owned_room_id!("!room:localhost"),
640
            OwnedTransactionId::from("upload"),
641
        )
642
    }
643
644
    fn update(update: RoomSendQueueUpdate) -> SendQueueUpdate {
645
        SendQueueUpdate {
646
            room_id: upload().0,
647
            update,
648
        }
649
    }
650
651
    fn tick() -> SendQueueUpdate {
652
        update(RoomSendQueueUpdate::MediaUpload {
653
            related_to: upload().1,
654
            file: None,
655
            index: 0,
656
            progress: AbstractProgress {
657
                current: 1,
658
                total: 2,
659
            },
660
        })
661
    }
662
663
    fn sent() -> SendQueueUpdate {
664
        update(RoomSendQueueUpdate::SentEvent {
665
            transaction_id: upload().1,
666
            event_id: event_id!("$sent:localhost").to_owned(),
667
        })
668
    }
669
670
    fn finished(event: Option<Event>) -> Option<UploadOutcome> {
671
        match event? {
672
            Event::MediaUploadFinished { outcome, .. } => Some(outcome),
673
            _ => None,
674
        }
675
    }
676
677
    #[test]
678
    fn a_tick_trailing_its_end_says_nothing() {
679
        let mut tracker = UploadTracker::new(HashSet::new());
680
        assert!(tracker.update(tick()).is_some());
681
        assert!(matches!(
682
            finished(tracker.update(sent())),
683
            Some(UploadOutcome::Sent { .. })
684
        ));
685
        assert!(tracker.update(tick()).is_none());
686
        assert!(tracker.live.is_empty());
687
    }
688
689
    #[test]
690
    fn a_stale_tick_after_a_missed_end_changes_nothing() {
691
        let mut tracker = UploadTracker::new(HashSet::new());
692
        assert!(tracker.update(tick()).is_some());
693
        let missed = tracker.resnapshot(HashSet::new());
694
        assert!(matches!(
695
            missed.as_slice(),
696
            [Event::MediaUploadFinished {
697
                outcome: UploadOutcome::Missed,
698
                ..
699
            }]
700
        ));
701
        assert!(tracker.update(tick()).is_none());
702
        assert!(tracker.update(sent()).is_none());
703
        assert!(tracker.live.is_empty());
704
    }
705
}