atagen

macltok

public
Star0
Watch0
2006a11ce142bf4e8a1e5ea7fdc6f96f79c4a603
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/engine/watchers.rsRust713 lines24.6 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(
380
    client: Client,
381
    events: EventSender,
382
    mut enabling: watch::Receiver<bool>,
383
) {
384
    let Some(device_id) = client.device_id().map(ToOwned::to_owned) else {
385
        tracing::warn!("encryption watcher stopped because the client has no device id");
386
        return;
387
    };
388
    let recovery = client.encryption().recovery();
389
    let backups = client.encryption().backups();
390
    let mut recovery_stream = recovery.state_stream();
391
    let mut backup_stream = backups.state_stream();
392
    let mut verification_stream = client.encryption().verification_state();
393
    let mut recovery_state = recovery.state();
394
    let mut backup_state = backups.state();
395
    let mut verification_state = verification_stream.get();
396
    loop {
397
        let mut state = encryption_state(
398
            device_id.clone(),
399
            recovery_state,
400
            verification_state,
401
            backup_state,
402
        );
403
        if *enabling.borrow_and_update() {
404
            state.recovery = crate::RecoveryState::Enabling;
405
        }
406
        if events.send(Event::EncryptionChanged(state)).await.is_err() {
407
            return;
408
        }
409
        tokio::select! {
410
            Some(update) = recovery_stream.next() => recovery_state = update,
411
            Ok(()) = enabling.changed() => {}
412
            Some(update) = verification_stream.next() => verification_state = update,
413
            Some(update) = backup_stream.next() => {
414
                backup_state = update.unwrap_or_else(|_| backups.state());
415
            }
416
            else => return,
417
        }
418
    }
419
}
420
421
/// space orders, pins and power levels all feed summaries without raising a notable room info update
422
pub(super) async fn rooms(client: Client, events: EventSender) {
423
    let (_, mut rooms) = client.rooms_stream();
424
    let mut info = client.room_info_notable_update_receiver();
425
    let changed = Arc::new(Notify::new());
426
    let handlers = [
427
        client.add_event_handler({
428
            let changed = changed.clone();
429
            move |event: Raw<AnyRoomAccountDataEvent>| {
430
                let changed = changed.clone();
431
                async move {
432
                    if event_type(&event).as_deref() == Some(space_order::EVENT_TYPE) {
433
                        changed.notify_one();
434
                    }
435
                }
436
            }
437
        }),
438
        client.add_event_handler({
439
            let changed = changed.clone();
440
            move |event: Raw<AnySyncStateEvent>| {
441
                let changed = changed.clone();
442
                async move {
443
                    if matches!(
444
                        event_type(&event).as_deref(),
445
                        Some("m.room.pinned_events" | "m.room.power_levels")
446
                    ) {
447
                        changed.notify_one();
448
                    }
449
                }
450
            }
451
        }),
452
    ];
453
    let (touches, mut touched) = mpsc::unbounded_channel();
454
    let thread_handlers = thread_handlers(&client, &touches);
455
    let mut timelines = client.event_cache().subscribe_to_room_generic_updates();
456
    'invalidate: loop {
457
        if events.send(Event::RoomsChanged).await.is_err() {
458
            break;
459
        }
460
        // thread events and threaded receipts leave room info alone, so neither reaches `info`
461
        let mut pending = Touched::default();
462
        loop {
463
            let mut invalidate = tokio::select! {
464
                update = rooms.next() => if update.is_none() { break 'invalidate } else { true },
465
                update = info.recv() => {
466
                    if matches!(update, Err(RecvError::Closed)) { break 'invalidate }
467
                    true
468
                }
469
                () = changed.notified() => true,
470
                Some((room_id, root)) = touched.recv() => {
471
                    pending.add(room_id, root);
472
                    false
473
                }
474
                update = timelines.recv() => match update {
475
                    Ok(update) => {
476
                        pending.add(update.room_id, None);
477
                        false
478
                    }
479
                    Err(RecvError::Lagged(_)) => true,
480
                    Err(RecvError::Closed) => break 'invalidate,
481
                },
482
            };
483
            // a sync's room info lands before the event cache has stored its events, so
484
            // give it a moment and take the whole sync as one invalidation
485
            matrix_sdk::sleep::sleep(SYNC_SETTLE).await;
486
            loop {
487
                match info.try_recv() {
488
                    Ok(_) | Err(TryRecvError::Lagged(_)) => invalidate = true,
489
                    Err(_) => break,
490
                }
491
            }
492
            while let Ok((room_id, root)) = touched.try_recv() {
493
                pending.add(room_id, root);
494
            }
495
            loop {
496
                match timelines.try_recv() {
497
                    Ok(update) => pending.add(update.room_id, None),
498
                    Err(TryRecvError::Lagged(_)) => invalidate = true,
499
                    Err(_) => break,
500
                }
501
            }
502
            for (room_id, roots) in pending.0.drain() {
503
                if let Some(room) = client.get_room(&room_id)
504
                    && recount_thread_unreads(&room, roots).await.is_some()
505
                {
506
                    invalidate = true;
507
                }
508
            }
509
            if invalidate {
510
                break;
511
            }
512
        }
513
    }
514
    for handler in handlers.into_iter().chain(thread_handlers) {
515
        client.remove_event_handler(handler);
516
    }
517
}
518
519
const SYNC_SETTLE: Duration = Duration::from_millis(100);
520
521
/// rooms to recount, with the threads a sync brought news of
522
#[derive(Default)]
523
struct Touched(HashMap<OwnedRoomId, HashSet<OwnedEventId>>);
524
525
impl Touched {
526
    fn add(&mut self, room_id: OwnedRoomId, root: Option<OwnedEventId>) {
527
        let roots = self.0.entry(room_id).or_default();
528
        roots.extend(root);
529
    }
530
}
531
532
/// a thread event names its thread before the event cache has it, and only the user's own
533
/// receipts move their counts
534
fn thread_handlers(
535
    client: &Client,
536
    touches: &mpsc::UnboundedSender<(OwnedRoomId, Option<OwnedEventId>)>,
537
) -> [EventHandlerHandle; 2] {
538
    let own_user_id = client.user_id().map(ToOwned::to_owned);
539
    [
540
        client.add_event_handler({
541
            let touches = touches.clone();
542
            move |event: Raw<AnySyncTimelineEvent>, room: Room| {
543
                if let Some(root) = extract_thread_root(&event) {
544
                    let _ = touches.send((room.room_id().to_owned(), Some(root)));
545
                }
546
                async {}
547
            }
548
        }),
549
        client.add_event_handler({
550
            let touches = touches.clone();
551
            move |event: SyncReceiptEvent, room: Room| {
552
                let own = own_user_id.as_ref().is_some_and(|own_user_id| {
553
                    event
554
                        .content
555
                        .0
556
                        .values()
557
                        .flat_map(|receipts| receipts.values())
558
                        .any(|users| users.contains_key(own_user_id))
559
                });
560
                if own {
561
                    let _ = touches.send((room.room_id().to_owned(), None));
562
                }
563
                async {}
564
            }
565
        }),
566
    ]
567
}
568
569
/// packs live in room state and account data, and leaving a room takes its packs with it
570
pub(super) async fn image_packs(client: Client, events: EventSender) {
571
    let changed = Arc::new(Notify::new());
572
    let handlers = [
573
        client.add_event_handler({
574
            let changed = changed.clone();
575
            move |event: Raw<AnySyncStateEvent>| {
576
                let changed = changed.clone();
577
                async move {
578
                    if event_type(&event).as_deref() == Some(ROOM_PACKS) {
579
                        changed.notify_one();
580
                    }
581
                }
582
            }
583
        }),
584
        client.add_event_handler({
585
            let changed = changed.clone();
586
            move |event: Raw<AnyGlobalAccountDataEvent>| {
587
                let changed = changed.clone();
588
                async move {
589
                    if matches!(
590
                        event_type(&event).as_deref(),
591
                        Some(USER_PACK | ENABLED_ROOMS)
592
                    ) {
593
                        changed.notify_one();
594
                    }
595
                }
596
            }
597
        }),
598
    ];
599
    let mut info = client.room_info_notable_update_receiver();
600
    loop {
601
        tokio::select! {
602
            () = changed.notified() => {}
603
            update = info.recv() => match update {
604
                Ok(update) if !update.reasons.contains(RoomInfoNotableUpdateReasons::MEMBERSHIP) => {
605
                    continue;
606
                }
607
                Ok(_) | Err(RecvError::Lagged(_)) => {}
608
                Err(RecvError::Closed) => break,
609
            },
610
        }
611
        if events.send(Event::ImagePacksChanged).await.is_err() {
612
            break;
613
        }
614
    }
615
    for handler in handlers {
616
        client.remove_event_handler(handler);
617
    }
618
}
619
620
fn event_type<T>(event: &Raw<T>) -> Option<String> {
621
    event.get_field("type").ok().flatten()
622
}
623
624
pub(super) async fn notification_settings(client: Client, events: EventSender) {
625
    let settings = client.notification_settings().await;
626
    let mut changes = settings.subscribe_to_changes();
627
    while let Ok(()) | Err(RecvError::Lagged(_)) = changes.recv().await {
628
        // room summaries carry the effective mode, and push rule changes never touch room info
629
        for event in [Event::NotificationSettingsChanged, Event::RoomsChanged] {
630
            if events.send(event).await.is_err() {
631
                return;
632
            }
633
        }
634
    }
635
}
636
637
#[cfg(test)]
638
mod tests {
639
    use matrix_sdk::send_queue::{AbstractProgress, RoomSendQueueUpdate, SendQueueUpdate};
640
    use ruma::{OwnedTransactionId, event_id, owned_room_id};
641
642
    use super::{HashSet, Upload, UploadTracker};
643
    use crate::{Event, UploadOutcome};
644
645
    fn upload() -> Upload {
646
        (
647
            owned_room_id!("!room:localhost"),
648
            OwnedTransactionId::from("upload"),
649
        )
650
    }
651
652
    fn update(update: RoomSendQueueUpdate) -> SendQueueUpdate {
653
        SendQueueUpdate {
654
            room_id: upload().0,
655
            update,
656
        }
657
    }
658
659
    fn tick() -> SendQueueUpdate {
660
        update(RoomSendQueueUpdate::MediaUpload {
661
            related_to: upload().1,
662
            file: None,
663
            index: 0,
664
            progress: AbstractProgress {
665
                current: 1,
666
                total: 2,
667
            },
668
        })
669
    }
670
671
    fn sent() -> SendQueueUpdate {
672
        update(RoomSendQueueUpdate::SentEvent {
673
            transaction_id: upload().1,
674
            event_id: event_id!("$sent:localhost").to_owned(),
675
        })
676
    }
677
678
    fn finished(event: Option<Event>) -> Option<UploadOutcome> {
679
        match event? {
680
            Event::MediaUploadFinished { outcome, .. } => Some(outcome),
681
            _ => None,
682
        }
683
    }
684
685
    #[test]
686
    fn a_tick_trailing_its_end_says_nothing() {
687
        let mut tracker = UploadTracker::new(HashSet::new());
688
        assert!(tracker.update(tick()).is_some());
689
        assert!(matches!(
690
            finished(tracker.update(sent())),
691
            Some(UploadOutcome::Sent { .. })
692
        ));
693
        assert!(tracker.update(tick()).is_none());
694
        assert!(tracker.live.is_empty());
695
    }
696
697
    #[test]
698
    fn a_stale_tick_after_a_missed_end_changes_nothing() {
699
        let mut tracker = UploadTracker::new(HashSet::new());
700
        assert!(tracker.update(tick()).is_some());
701
        let missed = tracker.resnapshot(HashSet::new());
702
        assert!(matches!(
703
            missed.as_slice(),
704
            [Event::MediaUploadFinished {
705
                outcome: UploadOutcome::Missed,
706
                ..
707
            }]
708
        ));
709
        assert!(tracker.update(tick()).is_none());
710
        assert!(tracker.update(sent()).is_none());
711
        assert!(tracker.live.is_empty());
712
    }
713
}