atagen

macltok

public
Star0
Watch0

Commit a319bae745d8

tarball

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

8 files changed+1088-3
mirror: fold the event stream into shared state for many subscribers

Every host was writing the same drain loop, and malice got it wrong in ways that only show under load, with colliding thread timelines, half-applied batches and a resync awaited where it can deadlock the engine. Mirror owns that loop behind the mirror feature, and snapshot and subscription share one lock and sequence number so a late subscriber sees no gap and nothing twice. It takes any event stream, so a host can still inspect the raw events.
Modified file: Cargo.toml+1 additions
@@ -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"]
Modified file: README.md+28 additions
@@ -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 between
6161 devices
6262
63+## The mirror
64+
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` drains
67+them into one `State` with the rooms, every open timeline, connection,
68+encryption, verification, typing, uploads and where the session stands. spawn
69+it the way you spawn `Engine::run`. it takes any stream of events, so wrap the
70+`EventStream` if you want to watch the raw ones go past
71+
72+`MirrorHandle` is cheap to clone, one per frontend. `snapshot` hands back the
73+current `State`, and `subscribe` opens with the whole `State` and then every
74+`Update` after it, numbered, with nothing missed and nothing twice. a timeline
75+update carries its diffs and the items they left, so apply the diffs or
76+re-render the list, whichever suits. a subscriber that falls behind gets a fresh
77+`State` instead of an error
78+
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 asking
81+until the reset or a close turns up. rooms are refetched on `RoomsChanged`, one
82+fetch at a time with at most one more owed
83+
84+with a store feature, `with_session_store` saves the record on every
85+`SessionTokensChanged` before the next event is touched, and a failed save shows
86+up as an update. without one, catch `SessionTokensChanged` on the raw stream
87+and save it yourself, a subscriber that lags could miss it. `save` is all the
88+mirror calls. `soft_log_out` and `discard` wait for `Engine::run`'s `Termination`
89+once the client is gone
90+
6391 ## wasm
6492
6593 macltok builds for `wasm32-unknown-unknown`. the `indexeddb` feature gives you
Modified file: docs/design-decisions.md+16 additions
@@ -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 racing
218218 cancel can't turn `Done` into `Cancelled`
219219
220+## mirror
221+
222+the mirror is a consumer like any other, behind the `mirror` feature, and it
223+takes any stream of events rather than the `EventStream` itself so a host keeps
224+the raw events without a second channel. state, its sequence number and the
225+broadcast send all move under one lock, which is how `subscribe` gets a
226+snapshot and a receiver with nothing between them. a lagged subscriber starts
227+over from a fresh state under the same lock
228+
229+nothing on the draining path waits for an engine answer. a resync runs on its
230+own task, retrying with backoff until a reset or a close for its timeline
231+aborts it. rooms are fetched on a task too, one at a time with one more owed at
232+most. the only thing it does wait for is `SessionStore::save`, since a refresh
233+token spent before the save lands loses the login. `SessionTokensChanged`
234+never becomes an update, a lagging subscriber would lose it
235+
220236 ## public model
221237
222238 owned ruma ids and typed data wherever there's a stable shape, `Raw` where
Modified file: src/engine/executor.rs+2 additions−2 deletions
@@ -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 {
Modified file: src/engine/mod.rs+1 additions−1 deletions
@@ -1,5 +1,5 @@
11 mod commands;
2-mod executor;
2+pub(crate) mod executor;
33 mod media;
44 mod mutations;
55 mod room;
Modified file: src/lib.rs+7 additions
@@ -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,
Added file: src/mirror.rs+638 additions
@@ -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 together
38+#[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 reset
61+ 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 tell
84+#[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 good
95+ 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 of
102+ 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 lagging
110+ State(Arc<State>),
111+ Authenticated(OwnedUserId),
112+ Session(SessionStatus),
113+ /// the record on disk may now hold a spent refresh token
114+ #[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 follows
121+ 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` left
151+ 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 reset
156+ Diverged,
157+ Closed,
158+}
159+
160+/// drains the event stream into a `State` that any number of `MirrorHandle`s watch
161+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 past
179+ 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 over
187+ 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, and
229+ /// calls nothing but `save`. the store can't go while the client holds it open
230+ #[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+ self
235+ }
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 = state
303+ .timelines
304+ .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+ state
344+ .typing
345+ .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 = state
376+ .uploads
377+ .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::Diverged
424+ }
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 answers
495+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 frontend
530+#[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 stops
542+ #[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+ formatter
570+ .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 subscription
593+ 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.published
607+ .lock()
608+ .unwrap_or_else(PoisonError::into_inner)
609+ }
610+
611+ /// state, sequence and send move together under the one lock, which is what lets
612+ /// `subscribe` take a snapshot and a receiver with nothing between them
613+ 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+}
Added file: tests/mirror.rs+395 additions
@@ -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 can
26+/// hand it a diff the engine never would
27+async fn start(config: EngineConfig, capacity: usize) -> Harness {
28+ let server = MatrixMockServer::new().await;
29+ server
30+ .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+ .await
41+ .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+ .await
73+ .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 at
96+#[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+ harness
102+ .inject
103+ .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+ harness
113+ .inject
114+ .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+ harness
132+ .inject
133+ .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+ harness
163+ .inject
164+ .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 = state
210+ .uploads
211+ .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+ harness
236+ .inject
237+ .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 !harness
248+ .mirror
249+ .snapshot()
250+ .uploads
251+ .get(&transaction_id)
252+ .is_some_and(|upload| upload.complete)
253+ {
254+ tokio::task::yield_now().await;
255+ }
256+ })
257+ .await
258+ .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+ harness
276+ .handle
277+ .open_room(harness.room_id.clone())
278+ .await
279+ .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+ harness
304+ .inject
305+ .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+ harness
332+ .handle
333+ .open_room(harness.room_id.clone())
334+ .await
335+ .unwrap();
336+ harness
337+ .handle
338+ .close_room(harness.room_id.clone())
339+ .await
340+ .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+ !harness
349+ .mirror
350+ .snapshot()
351+ .timelines
352+ .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+ harness
362+ .inject
363+ .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+ harness
377+ .mirror
378+ .snapshot()
379+ .uploads
380+ .contains_key(&transaction_id)
381+ );
382+ harness
383+ .inject
384+ .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+}