atagen

macltok

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