Commit a319bae745d8
tarball8 files changed+1088-3
@@ -6,6 +6,7 @@ description = "A compact application backend on top of the Matrix Rust SDK"
66
77
[features]88
default = []9
+mirror = []910
serde = ["dep:serde", "imbl/serde"]1011
sqlite = ["matrix-sdk/sqlite", "serde", "dep:url", "dep:matrix-sdk-store-encryption"]1112
bundled-sqlite = ["sqlite", "matrix-sdk/bundled-sqlite"]@@ -60,6 +60,34 @@ nothing, and the login overwrites it with a live record. `ShutDown` and
6060
one store directory per login, the sdk won't share a crypto store between6161
devices6262
63
+## The mirror64
+65
+the loop above is easy to get subtly wrong, so the `mirror` feature ships one.66
+`Mirror::new` takes a `ClientHandle` and the events, and `Mirror::run` drains67
+them into one `State` with the rooms, every open timeline, connection,68
+encryption, verification, typing, uploads and where the session stands. spawn69
+it the way you spawn `Engine::run`. it takes any stream of events, so wrap the70
+`EventStream` if you want to watch the raw ones go past71
+72
+`MirrorHandle` is cheap to clone, one per frontend. `snapshot` hands back the73
+current `State`, and `subscribe` opens with the whole `State` and then every74
+`Update` after it, numbered, with nothing missed and nothing twice. a timeline75
+update carries its diffs and the items they left, so apply the diffs or76
+re-render the list, whichever suits. a subscriber that falls behind gets a fresh77
+`State` instead of an error78
+79
+a batch that doesn't fit leaves the timeline diverged at its last good items.80
+the mirror asks for the resync itself, off the draining path, and keeps asking81
+until the reset or a close turns up. rooms are refetched on `RoomsChanged`, one82
+fetch at a time with at most one more owed83
+84
+with a store feature, `with_session_store` saves the record on every85
+`SessionTokensChanged` before the next event is touched, and a failed save shows86
+up as an update. without one, catch `SessionTokensChanged` on the raw stream87
+and save it yourself, a subscriber that lags could miss it. `save` is all the88
+mirror calls. `soft_log_out` and `discard` wait for `Engine::run`'s `Termination`89
+once the client is gone90
+6391
## wasm6492
6593
macltok builds for `wasm32-unknown-unknown`. the `indexeddb` feature gives you@@ -217,6 +217,22 @@ state, since the sdk only reports what changes after you ask. the watcher and
217217
the action paths claim the terminal event, so a racing218218
cancel can't turn `Done` into `Cancelled`219219
220
+## mirror221
+222
+the mirror is a consumer like any other, behind the `mirror` feature, and it223
+takes any stream of events rather than the `EventStream` itself so a host keeps224
+the raw events without a second channel. state, its sequence number and the225
+broadcast send all move under one lock, which is how `subscribe` gets a226
+snapshot and a receiver with nothing between them. a lagged subscriber starts227
+over from a fresh state under the same lock228
+229
+nothing on the draining path waits for an engine answer. a resync runs on its230
+own task, retrying with backoff until a reset or a close for its timeline231
+aborts it. rooms are fetched on a task too, one at a time with one more owed at232
+most. the only thing it does wait for is `SessionStore::save`, since a refresh233
+token spent before the save lands loses the login. `SessionTokensChanged`234
+never becomes an update, a lagging subscriber would lose it235
+220236
## public model221237
222238
owned ruma ids and typed data wherever there's a stable shape, `Raw` where@@ -5,9 +5,9 @@ use matrix_sdk::SendOutsideWasm;
55
pub(super) use matrix_sdk::executor::{AbortHandle, JoinError, JoinHandle, spawn};66
77
#[cfg(not(target_family = "wasm"))]8
-pub(super) type EngineBoxFuture<'a, T> = futures_util::future::BoxFuture<'a, T>;8
+pub(crate) type EngineBoxFuture<'a, T> = futures_util::future::BoxFuture<'a, T>;99
#[cfg(target_family = "wasm")]10
-pub(super) type EngineBoxFuture<'a, T> = futures_util::future::LocalBoxFuture<'a, T>;10
+pub(crate) type EngineBoxFuture<'a, T> = futures_util::future::LocalBoxFuture<'a, T>;1111
1212
#[cfg(not(target_family = "wasm"))]1313
pub(super) fn is_panic(error: &JoinError) -> bool {@@ -1,5 +1,5 @@
11
mod commands;2
-mod executor;2
+pub(crate) mod executor;33
mod media;44
mod mutations;55
mod room;@@ -3,6 +3,8 @@
33
mod encryption;44
mod engine;55
mod error;6
+#[cfg(feature = "mirror")]7
+mod mirror;68
mod model;79
mod normalise;810
mod transport;@@ -30,6 +32,11 @@ pub use session_store::{EncryptedSessionStore, SessionStore};
3032
pub use engine::{Engine, EngineConfig, EngineError};3133
pub use error::{Error, ErrorKind};3234
pub use imbl;35
+#[cfg(feature = "mirror")]36
+pub use mirror::{37
+ Mirror, MirrorHandle, SessionStatus, State, StateChange, Subscription, TimelineChange,38
+ TimelineState, Update, UploadFailure, UploadState,39
+};3340
pub use model::{3441
AccountCommand, AttachmentUpload, Change, Command, CommandOutput, ConnectionState,3542
CreateRoomOptions, EncryptionCommand, EncryptionState, EngineErrorKind, EngineFailure, Event,@@ -0,0 +1,638 @@
1
+use std::{2
+ collections::HashMap,3
+ fmt,4
+ pin::Pin,5
+ sync::{Arc, Mutex, MutexGuard, PoisonError},6
+ task::{Context, Poll},7
+ time::Duration,8
+};9
+10
+use futures_util::{Stream, StreamExt};11
+use imbl::Vector;12
+use matrix_sdk::{13
+ SendOutsideWasm,14
+ executor::{AbortHandle, spawn},15
+};16
+use ruma::{OwnedDeviceId, OwnedRoomId, OwnedTransactionId, OwnedUserId};17
+#[cfg(feature = "serde")]18
+use serde::{Deserialize, Serialize};19
+use tokio::sync::broadcast;20
+21
+use crate::{22
+ ClientHandle, Command, ConnectionState, EncryptionState, EngineFailure, ErrorKind, Event,23
+ HistoryState, RoomSummary, TimelineCommand, TimelineDiff, TimelineId, TimelineItem,24
+ TypingState, UploadOutcome, VerificationState, VerificationTarget,25
+ engine::executor::EngineBoxFuture,26
+};27
+28
+#[cfg(not(target_family = "wasm"))]29
+type EngineBoxStream<T> = futures_util::stream::BoxStream<'static, T>;30
+#[cfg(target_family = "wasm")]31
+type EngineBoxStream<T> = futures_util::stream::LocalBoxStream<'static, T>;32
+33
+const DEFAULT_CAPACITY: usize = 256;34
+const RESYNC_BACKOFF: Duration = Duration::from_millis(100);35
+const RESYNC_BACKOFF_CAP: Duration = Duration::from_secs(10);36
+37
+/// everything the engine has said so far, folded together38
+#[derive(Clone, Debug, PartialEq)]39
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]40
+#[non_exhaustive]41
+pub struct State {42
+ pub user_id: Option<OwnedUserId>,43
+ pub session: SessionStatus,44
+ pub connection: ConnectionState,45
+ pub encryption: Option<EncryptionState>,46
+ pub verification: Option<VerificationState>,47
+ pub verification_target: Option<VerificationTarget>,48
+ pub rooms: Vector<RoomSummary>,49
+ pub timelines: imbl::HashMap<TimelineId, TimelineState>,50
+ pub typing: imbl::HashMap<OwnedRoomId, Vec<OwnedUserId>>,51
+ pub uploads: imbl::HashMap<OwnedTransactionId, UploadState>,52
+}53
+54
+#[derive(Clone, Debug, PartialEq)]55
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]56
+#[non_exhaustive]57
+pub struct TimelineState {58
+ pub items: Vector<TimelineItem>,59
+ pub history: HistoryState,60
+ /// a batch didn't fit, so `items` is the last good copy until the next reset61
+ pub diverged: bool,62
+}63
+64
+#[derive(Clone, Debug, Eq, PartialEq)]65
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]66
+#[non_exhaustive]67
+pub struct UploadState {68
+ pub room_id: OwnedRoomId,69
+ pub uploaded_bytes: u64,70
+ pub total_bytes: u64,71
+ pub complete: bool,72
+ pub failure: Option<UploadFailure>,73
+}74
+75
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]76
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]77
+#[non_exhaustive]78
+pub struct UploadFailure {79
+ pub kind: ErrorKind,80
+ pub will_retry: bool,81
+}82
+83
+/// the `Termination` it'll end in, as far as the events tell84
+#[derive(Clone, Debug, Eq, PartialEq)]85
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]86
+#[non_exhaustive]87
+pub enum SessionStatus {88
+ Active,89
+ SoftLoggedOut {90
+ device_id: OwnedDeviceId,91
+ },92
+ AccessRevoked,93
+ LoggedOut,94
+ /// stopped with the session still good95
+ Stopped,96
+}97
+98
+#[derive(Clone, Debug)]99
+#[non_exhaustive]100
+pub struct Update {101
+ /// one more than the update before it, and a `State` carries the one it's current as of102
+ pub sequence: u64,103
+ pub change: StateChange,104
+}105
+106
+#[derive(Clone, Debug)]107
+#[non_exhaustive]108
+pub enum StateChange {109
+ /// start over from this. a subscription opens with one and gets another after lagging110
+ State(Arc<State>),111
+ Authenticated(OwnedUserId),112
+ Session(SessionStatus),113
+ /// the record on disk may now hold a spent refresh token114
+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]115
+ SessionSaveFailed(Arc<crate::AccountError>),116
+ Connection(ConnectionState),117
+ Encryption(EncryptionState),118
+ Verification(VerificationState),119
+ VerificationTarget(VerificationTarget),120
+ /// room modes arrive with the `Rooms` that follows121
+ NotificationSettings,122
+ Rooms(Vector<RoomSummary>),123
+ Timeline {124
+ timeline_id: TimelineId,125
+ change: TimelineChange,126
+ },127
+ History {128
+ timeline_id: TimelineId,129
+ state: HistoryState,130
+ },131
+ Typing(TypingState),132
+ Upload {133
+ transaction_id: OwnedTransactionId,134
+ upload: UploadState,135
+ },136
+ UploadFinished {137
+ room_id: OwnedRoomId,138
+ transaction_id: OwnedTransactionId,139
+ outcome: UploadOutcome,140
+ },141
+ Failed(EngineFailure),142
+}143
+144
+#[derive(Clone, Debug)]145
+#[non_exhaustive]146
+pub enum TimelineChange {147
+ Reset {148
+ items: Vector<TimelineItem>,149
+ },150
+ /// `items` is what applying `diffs` left151
+ Changed {152
+ diffs: Arc<[TimelineDiff]>,153
+ items: Vector<TimelineItem>,154
+ },155
+ /// a batch didn't fit. a resync is on its way and nothing changes until its reset156
+ Diverged,157
+ Closed,158
+}159
+160
+/// drains the event stream into a `State` that any number of `MirrorHandle`s watch161
+pub struct Mirror {162
+ handle: ClientHandle,163
+ events: EngineBoxStream<Event>,164
+ shared: Arc<Shared>,165
+ resyncs: HashMap<TimelineId, AbortHandle>,166
+ rooms_fetch: Option<AbortHandle>,167
+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]168
+ session_store: Option<Arc<dyn crate::SessionStore>>,169
+}170
+171
+impl fmt::Debug for Mirror {172
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {173
+ formatter.debug_struct("Mirror").finish_non_exhaustive()174
+ }175
+}176
+177
+impl Mirror {178
+ /// takes any stream of the engine's events, so a host can still look at them on the way past179
+ pub fn new(180
+ handle: ClientHandle,181
+ events: impl Stream<Item = Event> + SendOutsideWasm + 'static,182
+ ) -> (Self, MirrorHandle) {183
+ Self::with_capacity(handle, events, DEFAULT_CAPACITY)184
+ }185
+186
+ /// `capacity` updates can queue for a subscriber before it lags and starts over187
+ pub fn with_capacity(188
+ handle: ClientHandle,189
+ events: impl Stream<Item = Event> + SendOutsideWasm + 'static,190
+ capacity: usize,191
+ ) -> (Self, MirrorHandle) {192
+ let state = State {193
+ user_id: None,194
+ session: SessionStatus::Active,195
+ connection: *handle.connection_state().borrow(),196
+ encryption: handle.encryption_state(),197
+ verification: None,198
+ verification_target: None,199
+ rooms: Vector::new(),200
+ timelines: imbl::HashMap::new(),201
+ typing: imbl::HashMap::new(),202
+ uploads: imbl::HashMap::new(),203
+ };204
+ let shared = Arc::new(Shared {205
+ published: Mutex::new(Published {206
+ state: Arc::new(state),207
+ sequence: 0,208
+ updates: Some(broadcast::channel(capacity).0),209
+ rooms_fetch: RoomsFetch::Idle,210
+ }),211
+ });212
+ #[cfg(not(target_family = "wasm"))]213
+ let events = events.boxed();214
+ #[cfg(target_family = "wasm")]215
+ let events = events.boxed_local();216
+ let mirror = Self {217
+ handle,218
+ events,219
+ shared: shared.clone(),220
+ resyncs: HashMap::new(),221
+ rooms_fetch: None,222
+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]223
+ session_store: None,224
+ };225
+ (mirror, MirrorHandle { shared })226
+ }227
+228
+ /// saves the record on every `SessionTokensChanged` before taking another event, and229
+ /// calls nothing but `save`. the store can't go while the client holds it open230
+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]231
+ #[must_use]232
+ pub fn with_session_store(mut self, store: Arc<dyn crate::SessionStore>) -> Self {233
+ self.session_store = Some(store);234
+ self235
+ }236
+237
+ /// resolves once the event stream ends, which is just after `Stopped`238
+ pub fn run(mut self) -> EngineBoxFuture<'static, ()> {239
+ Box::pin(async move {240
+ self.refetch_rooms();241
+ while let Some(event) = self.events.next().await {242
+ self.apply(event).await;243
+ }244
+ })245
+ }246
+247
+ async fn apply(&mut self, event: Event) {248
+ match event {249
+ Event::ConnectionChanged(connection) => self.publish(|state| {250
+ state.connection = connection;251
+ Some(StateChange::Connection(connection))252
+ }),253
+ Event::Authenticated { user_id } => self.publish(|state| {254
+ state.user_id = Some(user_id.clone());255
+ Some(StateChange::Authenticated(user_id))256
+ }),257
+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]258
+ Event::SessionTokensChanged => self.save_session().await,259
+ #[cfg(not(any(feature = "sqlite", feature = "indexeddb")))]260
+ Event::SessionTokensChanged => {}261
+ Event::SessionAccessRejected { soft_logout } => self.publish(|state| {262
+ state.session = match soft_logout {263
+ Some(device_id) => SessionStatus::SoftLoggedOut { device_id },264
+ None => SessionStatus::AccessRevoked,265
+ };266
+ Some(StateChange::Session(state.session.clone()))267
+ }),268
+ Event::LoggedOut => self.publish(|state| {269
+ state.session = SessionStatus::LoggedOut;270
+ Some(StateChange::Session(SessionStatus::LoggedOut))271
+ }),272
+ Event::Stopped => {273
+ self.cancel_resyncs();274
+ self.publish(|state| {275
+ if state.session != SessionStatus::Active {276
+ return None;277
+ }278
+ state.session = SessionStatus::Stopped;279
+ Some(StateChange::Session(SessionStatus::Stopped))280
+ });281
+ }282
+ Event::Failed(failure) => self.publish(|_| Some(StateChange::Failed(failure))),283
+ Event::NotificationSettingsChanged => {284
+ self.publish(|_| Some(StateChange::NotificationSettings));285
+ }286
+ Event::RoomsChanged => self.refetch_rooms(),287
+ Event::EncryptionChanged(encryption) => self.publish(|state| {288
+ state.encryption = Some(encryption.clone());289
+ Some(StateChange::Encryption(encryption))290
+ }),291
+ Event::VerificationChanged(verification) => self.publish(|state| {292
+ state.verification = Some(verification.clone());293
+ Some(StateChange::Verification(verification))294
+ }),295
+ Event::VerificationTargetChanged(target) => self.publish(|state| {296
+ state.verification_target = Some(target.clone());297
+ Some(StateChange::VerificationTarget(target))298
+ }),299
+ Event::TimelineReset { timeline_id, items } => {300
+ self.cancel_resync(&timeline_id);301
+ self.publish(|state| {302
+ let history = state303
+ .timelines304
+ .get(&timeline_id)305
+ .map_or(HistoryState::Idle, |timeline| timeline.history.clone());306
+ let timeline = TimelineState {307
+ items: items.clone(),308
+ history,309
+ diverged: false,310
+ };311
+ state.timelines.insert(timeline_id.clone(), timeline);312
+ Some(StateChange::Timeline {313
+ timeline_id,314
+ change: TimelineChange::Reset { items },315
+ })316
+ });317
+ }318
+ Event::TimelineChanged { timeline_id, diffs } => self.apply_diffs(timeline_id, diffs),319
+ Event::HistoryChanged {320
+ timeline_id,321
+ state: history,322
+ } => self.publish(|state| {323
+ state.timelines.get_mut(&timeline_id)?.history = history.clone();324
+ Some(StateChange::History {325
+ timeline_id,326
+ state: history,327
+ })328
+ }),329
+ Event::TimelineClosed { timeline_id } => {330
+ self.cancel_resync(&timeline_id);331
+ self.publish(|state| {332
+ state.timelines.remove(&timeline_id)?;333
+ Some(StateChange::Timeline {334
+ timeline_id,335
+ change: TimelineChange::Closed,336
+ })337
+ });338
+ }339
+ Event::TypingChanged(typing) => self.publish(|state| {340
+ if typing.users.is_empty() {341
+ state.typing.remove(&typing.room_id);342
+ } else {343
+ state344
+ .typing345
+ .insert(typing.room_id.clone(), typing.users.clone());346
+ }347
+ Some(StateChange::Typing(typing))348
+ }),349
+ Event::MediaUploadProgress {350
+ room_id,351
+ transaction_id,352
+ uploaded_bytes,353
+ total_bytes,354
+ complete,355
+ } => self.publish(|state| {356
+ let upload = UploadState {357
+ room_id,358
+ uploaded_bytes,359
+ total_bytes,360
+ complete,361
+ failure: None,362
+ };363
+ state.uploads.insert(transaction_id.clone(), upload.clone());364
+ Some(StateChange::Upload {365
+ transaction_id,366
+ upload,367
+ })368
+ }),369
+ Event::MediaUploadFailed {370
+ room_id,371
+ transaction_id,372
+ kind,373
+ will_retry,374
+ } => self.publish(|state| {375
+ let upload = state376
+ .uploads377
+ .entry(transaction_id.clone())378
+ .or_insert_with(|| UploadState {379
+ room_id,380
+ uploaded_bytes: 0,381
+ total_bytes: 0,382
+ complete: false,383
+ failure: None,384
+ });385
+ upload.failure = Some(UploadFailure { kind, will_retry });386
+ Some(StateChange::Upload {387
+ transaction_id,388
+ upload: upload.clone(),389
+ })390
+ }),391
+ Event::MediaUploadFinished {392
+ room_id,393
+ transaction_id,394
+ outcome,395
+ } => self.publish(|state| {396
+ state.uploads.remove(&transaction_id);397
+ Some(StateChange::UploadFinished {398
+ room_id,399
+ transaction_id,400
+ outcome,401
+ })402
+ }),403
+ }404
+ }405
+406
+ fn apply_diffs(&mut self, timeline_id: TimelineId, diffs: Vec<TimelineDiff>) {407
+ let mut diverged = false;408
+ self.publish(|state| {409
+ let timeline = state.timelines.get_mut(&timeline_id)?;410
+ if timeline.diverged {411
+ return None;412
+ }413
+ let diffs: Arc<[TimelineDiff]> = diffs.into();414
+ let change = match TimelineDiff::try_apply_all(diffs.to_vec(), &mut timeline.items) {415
+ Ok(()) => TimelineChange::Changed {416
+ diffs,417
+ items: timeline.items.clone(),418
+ },419
+ Err(error) => {420
+ tracing::debug!(?timeline_id, length = error.length, "timeline diverged");421
+ timeline.diverged = true;422
+ diverged = true;423
+ TimelineChange::Diverged424
+ }425
+ };426
+ Some(StateChange::Timeline {427
+ timeline_id: timeline_id.clone(),428
+ change,429
+ })430
+ });431
+ if diverged {432
+ let task = spawn(resync(self.handle.clone(), timeline_id.clone()));433
+ self.resyncs.insert(timeline_id, task.abort_handle());434
+ }435
+ }436
+437
+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]438
+ async fn save_session(&mut self) {439
+ let Some(store) = &self.session_store else {440
+ return;441
+ };442
+ let saved = match self.handle.session_record() {443
+ Ok(record) => store.save(&record).await,444
+ Err(error) => Err(error),445
+ };446
+ if let Err(error) = saved {447
+ tracing::warn!(%error, "saving the session record failed");448
+ self.publish(|_| Some(StateChange::SessionSaveFailed(Arc::new(error))));449
+ }450
+ }451
+452
+ fn refetch_rooms(&mut self) {453
+ let mut published = self.shared.lock();454
+ match published.rooms_fetch {455
+ RoomsFetch::Idle => {456
+ published.rooms_fetch = RoomsFetch::Running;457
+ drop(published);458
+ let task = spawn(fetch_rooms(self.handle.clone(), self.shared.clone()));459
+ self.rooms_fetch = Some(task.abort_handle());460
+ }461
+ RoomsFetch::Running => published.rooms_fetch = RoomsFetch::Again,462
+ RoomsFetch::Again => {}463
+ }464
+ }465
+466
+ fn cancel_resync(&mut self, timeline_id: &TimelineId) {467
+ if let Some(task) = self.resyncs.remove(timeline_id) {468
+ task.abort();469
+ }470
+ }471
+472
+ fn cancel_resyncs(&mut self) {473
+ for (_, task) in self.resyncs.drain() {474
+ task.abort();475
+ }476
+ }477
+478
+ fn publish(&self, change: impl FnOnce(&mut State) -> Option<StateChange>) {479
+ self.shared.publish(change);480
+ }481
+}482
+483
+impl Drop for Mirror {484
+ fn drop(&mut self) {485
+ self.cancel_resyncs();486
+ if let Some(task) = self.rooms_fetch.take() {487
+ task.abort();488
+ }489
+ self.shared.lock().updates = None;490
+ }491
+}492
+493
+/// asks until the engine answers, since a busy room or a full queue is only ever for now.494
+/// a reset or a close for the timeline aborts it, and the engine sends a reset before it answers495
+async fn resync(handle: ClientHandle, timeline_id: TimelineId) {496
+ let mut backoff = RESYNC_BACKOFF;497
+ loop {498
+ let command = Command::Timeline(TimelineCommand::Resync {499
+ timeline_id: timeline_id.clone(),500
+ });501
+ match handle.execute(command).await {502
+ Ok(_) => return,503
+ Err(error) if error.kind == ErrorKind::Interrupted => return,504
+ Err(error) => {505
+ tracing::debug!(%error, ?timeline_id, "resync refused, trying again");506
+ matrix_sdk::sleep::sleep(backoff).await;507
+ backoff = (backoff * 2).min(RESYNC_BACKOFF_CAP);508
+ }509
+ }510
+ }511
+}512
+513
+async fn fetch_rooms(handle: ClientHandle, shared: Arc<Shared>) {514
+ loop {515
+ let rooms: Vector<RoomSummary> = handle.rooms().await.into();516
+ shared.publish(|state| {517
+ state.rooms = rooms.clone();518
+ Some(StateChange::Rooms(rooms))519
+ });520
+ let mut published = shared.lock();521
+ if published.rooms_fetch != RoomsFetch::Again {522
+ published.rooms_fetch = RoomsFetch::Idle;523
+ return;524
+ }525
+ published.rooms_fetch = RoomsFetch::Running;526
+ }527
+}528
+529
+/// cheap to clone, one per frontend530
+#[derive(Clone, Debug)]531
+pub struct MirrorHandle {532
+ shared: Arc<Shared>,533
+}534
+535
+impl MirrorHandle {536
+ #[must_use]537
+ pub fn snapshot(&self) -> Arc<State> {538
+ self.shared.lock().state.clone()539
+ }540
+541
+ /// opens with the current state, then every update after it. ends once the mirror stops542
+ #[must_use]543
+ pub fn subscribe(&self) -> Subscription {544
+ let (first, receiving) = self.shared.clone().subscribe();545
+ let rest = futures_util::stream::unfold(receiving, |(shared, receiver)| async move {546
+ let mut receiver = receiver?;547
+ match receiver.recv().await {548
+ Ok(update) => Some((update, (shared, Some(receiver)))),549
+ Err(broadcast::error::RecvError::Lagged(missed)) => {550
+ tracing::debug!(missed, "mirror subscriber lagged, starting it over");551
+ Some(shared.subscribe())552
+ }553
+ Err(broadcast::error::RecvError::Closed) => None,554
+ }555
+ });556
+ let updates = futures_util::stream::once(std::future::ready(first)).chain(rest);557
+ #[cfg(not(target_family = "wasm"))]558
+ let updates = updates.boxed();559
+ #[cfg(target_family = "wasm")]560
+ let updates = updates.boxed_local();561
+ Subscription(updates)562
+ }563
+}564
+565
+pub struct Subscription(EngineBoxStream<Update>);566
+567
+impl fmt::Debug for Subscription {568
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {569
+ formatter570
+ .debug_struct("Subscription")571
+ .finish_non_exhaustive()572
+ }573
+}574
+575
+impl Stream for Subscription {576
+ type Item = Update;577
+578
+ fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Update>> {579
+ self.0.poll_next_unpin(context)580
+ }581
+}582
+583
+#[derive(Debug)]584
+struct Shared {585
+ published: Mutex<Published>,586
+}587
+588
+#[derive(Debug)]589
+struct Published {590
+ state: Arc<State>,591
+ sequence: u64,592
+ /// gone once the mirror stops, which ends every subscription593
+ updates: Option<broadcast::Sender<Update>>,594
+ rooms_fetch: RoomsFetch,595
+}596
+597
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]598
+enum RoomsFetch {599
+ Idle,600
+ Running,601
+ Again,602
+}603
+604
+impl Shared {605
+ fn lock(&self) -> MutexGuard<'_, Published> {606
+ self.published607
+ .lock()608
+ .unwrap_or_else(PoisonError::into_inner)609
+ }610
+611
+ /// state, sequence and send move together under the one lock, which is what lets612
+ /// `subscribe` take a snapshot and a receiver with nothing between them613
+ fn publish(&self, change: impl FnOnce(&mut State) -> Option<StateChange>) {614
+ let mut published = self.lock();615
+ let Some(change) = change(Arc::make_mut(&mut published.state)) else {616
+ return;617
+ };618
+ published.sequence += 1;619
+ let update = Update {620
+ sequence: published.sequence,621
+ change,622
+ };623
+ if let Some(updates) = &published.updates {624
+ let _ = updates.send(update);625
+ }626
+ }627
+628
+ fn subscribe(self: Arc<Self>) -> (Update, (Arc<Self>, Option<broadcast::Receiver<Update>>)) {629
+ let published = self.lock();630
+ let update = Update {631
+ sequence: published.sequence,632
+ change: StateChange::State(published.state.clone()),633
+ };634
+ let receiver = published.updates.as_ref().map(broadcast::Sender::subscribe);635
+ drop(published);636
+ (update, (self, receiver))637
+ }638
+}@@ -0,0 +1,395 @@
1
+#![cfg(feature = "mirror")]2
+3
+use std::time::Duration;4
+5
+use futures_util::StreamExt;6
+use macltok::{7
+ ClientHandle, Command, Engine, EngineConfig, Event, Mirror, MirrorHandle, StateChange,8
+ Subscription, TimelineChange, TimelineCommand, TimelineDiff, TimelineId, TimelineItem, Update,9
+ UploadOutcome,10
+};11
+use matrix_sdk::{12
+ ruma::{OwnedRoomId, TransactionId, api::client::sync::sync_events::v5, event_id, room_id},13
+ test_utils::mocks::MatrixMockServer,14
+};15
+use tokio::sync::mpsc;16
+17
+struct Harness {18
+ handle: ClientHandle,19
+ mirror: MirrorHandle,20
+ inject: mpsc::UnboundedSender<Event>,21
+ room_id: OwnedRoomId,22
+ _server: MatrixMockServer,23
+}24
+25
+/// the mirror reads the real engine's stream with synthetic events mixed in, so a test can26
+/// hand it a diff the engine never would27
+async fn start(config: EngineConfig, capacity: usize) -> Harness {28
+ let server = MatrixMockServer::new().await;29
+ server30
+ .mock_sliding_sync()31
+ .ok(v5::Response::new("1".to_owned()))32
+ .mount()33
+ .await;34
+ let client = server.client_builder().build().await;35
+ let room_id = room_id!("!mirror:localhost");36
+ server.mock_room_state_encryption().plain().mount().await;37
+ server.sync_joined_room(&client, room_id).await;38
+ let (engine, handle, events) =39
+ Engine::from_client(client, config.with_initial_history_limit(0))40
+ .await41
+ .unwrap();42
+ tokio::spawn(engine.run());43
+ let (inject, mut injected) = mpsc::unbounded_channel();44
+ let injected = futures_util::stream::poll_fn(move |context| injected.poll_recv(context));45
+ let (mirror, mirror_handle) = Mirror::with_capacity(46
+ handle.clone(),47
+ futures_util::stream::select(events, injected),48
+ capacity,49
+ );50
+ tokio::spawn(mirror.run());51
+ Harness {52
+ handle,53
+ mirror: mirror_handle,54
+ inject,55
+ room_id: room_id.to_owned(),56
+ _server: server,57
+ }58
+}59
+60
+async fn next_matching(61
+ subscription: &mut Subscription,62
+ predicate: impl Fn(&StateChange) -> bool,63
+) -> Update {64
+ tokio::time::timeout(Duration::from_secs(5), async {65
+ loop {66
+ let update = subscription.next().await.expect("the mirror stopped");67
+ if predicate(&update.change) {68
+ return update;69
+ }70
+ }71
+ })72
+ .await73
+ .expect("expected update did not arrive")74
+}75
+76
+fn timeline_change(77
+ change: &StateChange,78
+ id: &TimelineId,79
+ predicate: impl Fn(&TimelineChange) -> bool,80
+) -> bool {81
+ matches!(change, StateChange::Timeline { timeline_id, change } if timeline_id == id && predicate(change))82
+}83
+84
+fn item(timestamp_ms: u64) -> TimelineItem {85
+ TimelineItem::DayDivider { timestamp_ms }86
+}87
+88
+fn reset(timeline_id: &TimelineId, items: Vec<TimelineItem>) -> Event {89
+ Event::TimelineReset {90
+ timeline_id: timeline_id.clone(),91
+ items: items.into(),92
+ }93
+}94
+95
+/// the room never opens, so every resync is refused and the divergence holds still to be looked at96
+#[tokio::test]97
+async fn diverged_batch_keeps_last_good_items_until_a_reset() {98
+ let harness = start(EngineConfig::default(), 256).await;99
+ let timeline_id = TimelineId::from(harness.room_id.clone());100
+ let mut updates = harness.mirror.subscribe();101
+ harness102
+ .inject103
+ .send(reset(&timeline_id, vec![item(1), item(2)]))104
+ .unwrap();105
+ for diffs in [106
+ vec![107
+ TimelineDiff::PushBack { value: item(3) },108
+ TimelineDiff::Remove { index: 7 },109
+ ],110
+ vec![TimelineDiff::PushBack { value: item(4) }],111
+ ] {112
+ harness113
+ .inject114
+ .send(Event::TimelineChanged {115
+ timeline_id: timeline_id.clone(),116
+ diffs,117
+ })118
+ .unwrap();119
+ }120
+ next_matching(&mut updates, |change| {121
+ timeline_change(change, &timeline_id, |change| {122
+ matches!(change, TimelineChange::Diverged)123
+ })124
+ })125
+ .await;126
+ let snapshot = harness.mirror.snapshot();127
+ let timeline = &snapshot.timelines[&timeline_id];128
+ assert!(timeline.diverged);129
+ assert_eq!(timeline.items, [item(1), item(2)].into_iter().collect());130
+131
+ harness132
+ .inject133
+ .send(reset(&timeline_id, vec![item(5)]))134
+ .unwrap();135
+ let update = next_matching(&mut updates, |change| {136
+ timeline_change(change, &timeline_id, |_| true)137
+ })138
+ .await;139
+ assert!(140
+ timeline_change(&update.change, &timeline_id, |change| {141
+ matches!(change, TimelineChange::Reset { .. })142
+ }),143
+ "a diverged timeline took {update:?}"144
+ );145
+ let snapshot = harness.mirror.snapshot();146
+ let timeline = &snapshot.timelines[&timeline_id];147
+ assert!(!timeline.diverged);148
+ assert_eq!(timeline.items, [item(5)].into_iter().collect());149
+}150
+151
+#[tokio::test]152
+async fn room_and_thread_timelines_stay_apart() {153
+ let harness = start(EngineConfig::default(), 256).await;154
+ let room = TimelineId::from(harness.room_id.clone());155
+ let thread = TimelineId::thread(156
+ harness.room_id.clone(),157
+ event_id!("$root:localhost").to_owned(),158
+ );159
+ let mut updates = harness.mirror.subscribe();160
+ harness.inject.send(reset(&room, vec![item(1)])).unwrap();161
+ harness.inject.send(reset(&thread, vec![item(2)])).unwrap();162
+ harness163
+ .inject164
+ .send(Event::TimelineChanged {165
+ timeline_id: thread.clone(),166
+ diffs: vec![TimelineDiff::PushBack { value: item(3) }],167
+ })168
+ .unwrap();169
+ next_matching(&mut updates, |change| {170
+ timeline_change(change, &thread, |change| {171
+ matches!(change, TimelineChange::Changed { .. })172
+ })173
+ })174
+ .await;175
+ let snapshot = harness.mirror.snapshot();176
+ assert_eq!(177
+ snapshot.timelines[&room].items,178
+ [item(1)].into_iter().collect()179
+ );180
+ assert_eq!(181
+ snapshot.timelines[&thread].items,182
+ [item(2), item(3)].into_iter().collect()183
+ );184
+}185
+186
+#[tokio::test]187
+async fn late_subscriber_gets_state_then_every_update_after_it() {188
+ let harness = start(EngineConfig::default(), 1024).await;189
+ let transaction_id = TransactionId::new();190
+ let progress = |uploaded_bytes| Event::MediaUploadProgress {191
+ room_id: harness.room_id.clone(),192
+ transaction_id: transaction_id.clone(),193
+ uploaded_bytes,194
+ total_bytes: 100,195
+ complete: false,196
+ };197
+ for uploaded_bytes in 1..=50 {198
+ harness.inject.send(progress(uploaded_bytes)).unwrap();199
+ }200
+ let mut updates = harness.mirror.subscribe();201
+ for uploaded_bytes in 51..=100 {202
+ harness.inject.send(progress(uploaded_bytes)).unwrap();203
+ }204
+205
+ let first = updates.next().await.unwrap();206
+ let StateChange::State(state) = &first.change else {207
+ panic!("a subscription opened with {first:?}");208
+ };209
+ let mut seen = state210
+ .uploads211
+ .get(&transaction_id)212
+ .map_or(0, |upload| upload.uploaded_bytes);213
+ let mut sequence = first.sequence;214
+ while seen < 100 {215
+ let update = next_matching(&mut updates, |_| true).await;216
+ assert_eq!(update.sequence, sequence + 1);217
+ sequence = update.sequence;218
+ if let StateChange::Upload { upload, .. } = update.change {219
+ assert_eq!(upload.uploaded_bytes, seen + 1);220
+ seen += 1;221
+ }222
+ }223
+}224
+225
+#[tokio::test]226
+async fn lagging_subscriber_starts_over_from_a_fresh_state() {227
+ let harness = start(EngineConfig::default(), 4).await;228
+ let transaction_id = TransactionId::new();229
+ let mut updates = harness.mirror.subscribe();230
+ assert!(matches!(231
+ updates.next().await.unwrap().change,232
+ StateChange::State(_)233
+ ));234
+ for uploaded_bytes in 1..=20 {235
+ harness236
+ .inject237
+ .send(Event::MediaUploadProgress {238
+ room_id: harness.room_id.clone(),239
+ transaction_id: transaction_id.clone(),240
+ uploaded_bytes,241
+ total_bytes: 20,242
+ complete: uploaded_bytes == 20,243
+ })244
+ .unwrap();245
+ }246
+ tokio::time::timeout(Duration::from_secs(5), async {247
+ while !harness248
+ .mirror249
+ .snapshot()250
+ .uploads251
+ .get(&transaction_id)252
+ .is_some_and(|upload| upload.complete)253
+ {254
+ tokio::task::yield_now().await;255
+ }256
+ })257
+ .await258
+ .expect("the mirror never caught up");259
+260
+ let update = updates.next().await.unwrap();261
+ let StateChange::State(state) = &update.change else {262
+ panic!("a lagged subscription carried on with {update:?}");263
+ };264
+ assert!(state.uploads[&transaction_id].complete);265
+}266
+267
+#[tokio::test]268
+async fn resync_does_not_deadlock_on_full_queues() {269
+ let config = EngineConfig::default()270
+ .with_command_capacity(1)271
+ .with_event_capacity(1);272
+ let harness = start(config, 1024).await;273
+ let timeline_id = TimelineId::from(harness.room_id.clone());274
+ let mut updates = harness.mirror.subscribe();275
+ harness276
+ .handle277
+ .open_room(harness.room_id.clone())278
+ .await279
+ .unwrap();280
+ next_matching(&mut updates, |change| {281
+ timeline_change(change, &timeline_id, |change| {282
+ matches!(change, TimelineChange::Reset { .. })283
+ })284
+ })285
+ .await;286
+287
+ let flood = tokio::spawn({288
+ let handle = harness.handle.clone();289
+ let timeline_id = timeline_id.clone();290
+ async move {291
+ loop {292
+ let command = Command::Timeline(TimelineCommand::Paginate {293
+ timeline_id: timeline_id.clone(),294
+ limit: 1,295
+ });296
+ if handle.send(command).await.is_err() {297
+ return;298
+ }299
+ }300
+ }301
+ });302
+ for _ in 0..10 {303
+ harness304
+ .inject305
+ .send(Event::TimelineChanged {306
+ timeline_id: timeline_id.clone(),307
+ diffs: vec![TimelineDiff::Remove { index: 1000 }],308
+ })309
+ .unwrap();310
+ next_matching(&mut updates, |change| {311
+ timeline_change(change, &timeline_id, |change| {312
+ matches!(change, TimelineChange::Diverged)313
+ })314
+ })315
+ .await;316
+ next_matching(&mut updates, |change| {317
+ timeline_change(change, &timeline_id, |change| {318
+ matches!(change, TimelineChange::Reset { .. })319
+ })320
+ })321
+ .await;322
+ }323
+ flood.abort();324
+}325
+326
+#[tokio::test]327
+async fn closing_a_timeline_evicts_it() {328
+ let harness = start(EngineConfig::default(), 256).await;329
+ let timeline_id = TimelineId::from(harness.room_id.clone());330
+ let mut updates = harness.mirror.subscribe();331
+ harness332
+ .handle333
+ .open_room(harness.room_id.clone())334
+ .await335
+ .unwrap();336
+ harness337
+ .handle338
+ .close_room(harness.room_id.clone())339
+ .await340
+ .unwrap();341
+ next_matching(&mut updates, |change| {342
+ timeline_change(change, &timeline_id, |change| {343
+ matches!(change, TimelineChange::Closed)344
+ })345
+ })346
+ .await;347
+ assert!(348
+ !harness349
+ .mirror350
+ .snapshot()351
+ .timelines352
+ .contains_key(&timeline_id)353
+ );354
+}355
+356
+#[tokio::test]357
+async fn an_upload_ends_on_finished() {358
+ let harness = start(EngineConfig::default(), 256).await;359
+ let transaction_id = TransactionId::new();360
+ let mut updates = harness.mirror.subscribe();361
+ harness362
+ .inject363
+ .send(Event::MediaUploadProgress {364
+ room_id: harness.room_id.clone(),365
+ transaction_id: transaction_id.clone(),366
+ uploaded_bytes: 5,367
+ total_bytes: 10,368
+ complete: false,369
+ })370
+ .unwrap();371
+ next_matching(&mut updates, |change| {372
+ matches!(change, StateChange::Upload { .. })373
+ })374
+ .await;375
+ assert!(376
+ harness377
+ .mirror378
+ .snapshot()379
+ .uploads380
+ .contains_key(&transaction_id)381
+ );382
+ harness383
+ .inject384
+ .send(Event::MediaUploadFinished {385
+ room_id: harness.room_id.clone(),386
+ transaction_id: transaction_id.clone(),387
+ outcome: UploadOutcome::Cancelled,388
+ })389
+ .unwrap();390
+ next_matching(&mut updates, |change| {391
+ matches!(change, StateChange::UploadFinished { .. })392
+ })393
+ .await;394
+ assert!(harness.mirror.snapshot().uploads.is_empty());395
+}