atagen

macltok

public
Star0
Watch0

Commit fa9114fcf75f

tarball

atagen <boss@atagen.co>2026-09-24 13:05 UTC

15 files changed+453-156
engine: fix sync, send queue, media cache and session lifecycle bugs
Modified file: Cargo.toml+1 additions−1 deletions
@@ -20,7 +20,7 @@ ruma = { version = "0.16.0", default-features = false }
2020 serde = { version = "1.0.219", features = ["derive", "rc"], optional = true }
2121 thiserror = "2.0.16"
2222 mime = "0.3.17"
23-tokio = { version = "1.47.1", features = ["rt", "sync"] }
23+tokio = { version = "1.47.1", features = ["rt", "sync", "time"] }
2424 tracing = "0.1.41"
2525 url = { version = "2.5.7", optional = true }
2626
Modified file: README.md+8 additions−3 deletions
@@ -35,9 +35,14 @@ after its fresh `TimelineReset`; the reset precedes the command completion.
3535 With `sqlite`, persist the initial `Session::record()` separately from its
3636 encrypted SQLite store. On `SessionTokensChanged`, obtain a new record from
3737 `ClientHandle::session_record()` and replace the saved record atomically in
38-protected storage. Restore with the same store path and passphrase. Delete the
39-saved credentials after `LoggedOut` or `SessionAccessRejected`, but keep them
40-after `Stopped` so a restart can restore the session. The headless example is
38+protected storage. Restore with the same store path and passphrase.
39+
40+`LoggedOut` and `SessionAccessRejected` both stop the engine. after a logout or a
41+hard rejection the record and the store are dead, bin them. a soft logout wants
42+the store kept and a fresh login with `PasswordLogin::device_id` set to the old
43+device, or the e2ee keys go with it. a bare `Stopped` means keep everything. one
44+store directory per login, the sdk won't share a crypto store between devices.
45+The headless example is
4146 login-only; it does not persist sessions across runs.
4247
4348 ## Dependency policy
Modified file: docs/design-decisions.md+4 additions−3 deletions
@@ -99,9 +99,10 @@ login, then consume `SessionTokensChanged` and atomically replace that saved
9999 record with `ClientHandle::session_record()`. A previously captured record may
100100 hold an exhausted refresh token; failing to persist a rotation must be surfaced
101101 rather than silently treating the stale record as safe to restore. Restore with
102-the same `StoreConfig` path and passphrase. Explicit logout and
103-`SessionAccessRejected` invalidate the saved record and store; `Stopped` alone
104-does not. Neither cookies nor application-specific secret storage belong in the
102+the same `StoreConfig` path and passphrase. logout and a rejected token both
103+stop the engine. logout and a hard rejection kill the record and the store, a
104+soft logout keeps the store for a same-device login, `Stopped` alone kills
105+nothing. Neither cookies nor application-specific secret storage belong in the
105106 toolkit.
106107
107108 ## Notifications and room data
Modified file: docs/review-2026-09-24.md+14 additions−14 deletions
@@ -14,7 +14,7 @@ pass. The sqlite and clippy runs are recorded at the end.
1414
1515 ## C. Correctness
1616
17-### C1. `Verify(Reject)` never rejects. OPEN
17+### C1. `Verify(Reject)` never rejects. done
1818
1919 `VerificationController::reject` (`src/encryption.rs:339-389`) validates the
2020 SAS state twice and returns `Ok(())` without ever calling `sas.mismatch()`. The
@@ -23,7 +23,7 @@ comparison, and the command reports success. A user who presses "they don't
2323 match" gets no protection from the one action meant to provide it. No test
2424 exercises `Reject`.
2525
26-### C2. Sync dies permanently after `SyncState::Error`. OPEN
26+### C2. Sync dies permanently after `SyncState::Error`. done
2727
2828 The connection watcher maps `SyncState::Error` to `ConnectionState::Offline`
2929 (`src/engine.rs:1338-1341`) and nothing ever calls `SyncService::start` again.
@@ -38,7 +38,7 @@ becomes `ConnectionState::Stopped` while the engine keeps running, so
3838 Fix direction. Restart with backoff on `Error` and `Terminated`, and report
3939 `Stopped` only from engine shutdown.
4040
41-### C3. A room's send queue stays disabled after any send failure. OPEN
41+### C3. A room's send queue stays disabled after any send failure. done
4242
4343 The SDK disables a room's queue after every send error, recoverable or not
4444 (`matrix-sdk send_queue/mod.rs:1011-1019`), and nothing in matrix-sdk-ui turns
@@ -51,7 +51,7 @@ running.
5151 Fix direction. Re-enable on each transition to `Online`, and keep
5252 `RetrySend` for unwedging a single item.
5353
54-### C4. Own encrypted uploads cannot be fetched, and the media cache is split between two formats. OPEN
54+### C4. Own encrypted uploads cannot be fetched, and the media cache is split between two formats. done, with a round trip test through the sdk cache
5555
5656 macltok writes ciphertext into the media store under the `Encrypted` source key
5757 and decrypts on every read (`src/engine.rs:2799-2821`, `2864-2874`). The SDK
@@ -68,7 +68,7 @@ ciphertext as if it were the image. The media tests force plaintext rooms
6868 Fix direction. Cache plaintext, matching the SDK, and keep the byte bound by
6969 decrypting in a streaming fashion before the write.
7070
71-### C5. Media single-flight is poisoned by a leader panic. OPEN
71+### C5. Media single-flight is poisoned by a leader panic. folded into the media query rewrite under q1
7272
7373 In `spawn_media_fetch` (`src/engine.rs:1870-1911`) followers push their id and
7474 return at once, trusting the leader to answer for them. If `fetch_media`
@@ -79,7 +79,7 @@ completes. Largely superseded if media fetch becomes a direct query (see
7979 M2), but the fix is the same either way. Use a drop guard or a shared future
8080 so the entry always clears.
8181
82-### C6. Back pagination can stick at "start reached" forever. OPEN
82+### C6. Back pagination can stick at "start reached" forever. done, the sdk already remembers
8383
8484 `reached_start` is a cached `AtomicBool` that is only ever set from a
8585 pagination result (`src/engine.rs:4060-4071`, `4097`). A limited sync with a
@@ -92,7 +92,7 @@ room scrolls to a stub and stops.
9292 Fix direction. Drop the flag and follow `Timeline::live_back_pagination_status`
9393 (`matrix-sdk-ui timeline/pagination.rs:130-138`), which is authoritative.
9494
95-### C7. Soft logout destroys the store. OPEN
95+### C7. Soft logout destroys the store. done, `SessionAccessRejected { soft_logout }` and a same-device login
9696
9797 `SessionChange::UnknownToken` carries `soft_logout`
9898 (`matrix-sdk client/mod.rs:166`), but the watcher collapses it into a
@@ -102,7 +102,7 @@ store. On a soft logout the correct response is to re-authenticate and keep
102102 the store, so following the documentation throws away every E2EE key for
103103 nothing.
104104
105-### C8. Logout and rejected access leave the engine running. OPEN
105+### C8. Logout and rejected access leave the engine running. done
106106
107107 `spawn_logout` emits `LoggedOut` and returns (`src/engine.rs:825-852`). The
108108 sync service, watchers and room actors keep running against a dead session,
@@ -110,7 +110,7 @@ and every later command fails with an authentication error. Neither logout nor
110110 a hard `UnknownToken` shuts the engine down. The engine should stop itself,
111111 emitting `LoggedOut` or the rejection before `Stopped`.
112112
113-### C9. `MarkRead` can move the read marker backwards and costs two requests. OPEN
113+### C9. `MarkRead` can move the read marker backwards and costs two requests. done, open rooms go through the timeline so stale receipts get dropped
114114
115115 `mark_read` (`src/engine.rs:2072-2095`) calls `Room::send_single_receipt`
116116 twice. That call does no ordering check
@@ -125,14 +125,14 @@ Fix direction. When the room is open, route through the actor and use
125125 `Timeline::send_multiple_receipts`. Otherwise use a single
126126 `Room::send_multiple_receipts`.
127127
128-### C10. `create_direct` always creates a new room. OPEN
128+### C10. `create_direct` always creates a new room. done
129129
130130 `Client::create_dm` never reuses an existing DM
131131 (`matrix-sdk client/mod.rs:1830-1849`). Starting a chat with someone you
132132 already talk to creates a duplicate every time. `Client::get_dm_room` exists
133133 for the lookup (`client/mod.rs:1852`).
134134
135-### C11. Unregistered tasks swallow panics, contradicting the design doc. OPEN
135+### C11. Unregistered tasks swallow panics, contradicting the design doc. folded into q1 and the actor restructure
136136
137137 The design doc says a watcher panic is terminal. But the timeline watcher
138138 (`src/engine.rs:589`), pagination worker (`446`), pagination result task
@@ -143,7 +143,7 @@ account-data writers (`src/engine.rs:1111`, `1141`) and the per-room media
143143 upload watcher (`1945`) are registered as `TaskContext::Watcher`, so a panic
144144 in a single write kills the whole engine.
145145
146-### C12. The room actor blocks on network requests. OPEN
146+### C12. The room actor blocks on network requests. done, redact reactions and receipts run beside the actor. replies stay serial so sends keep their order
147147
148148 Each room actor runs its commands serially and awaits each one. Most sends
149149 only enqueue, but `Timeline::redact` on a remote event is a direct HTTP call
@@ -152,7 +152,7 @@ only enqueue, but `Timeline::redact` on a remote event is a direct HTTP call
152152 event (`room/reply.rs:108`). A slow homeserver therefore stalls every later
153153 send, paginate and thread command in that room behind one redaction.
154154
155-### C13. Media registry evicts media that is still on screen. OPEN
155+### C13. Media registry evicts media that is still on screen. done, lru by stamp and released on close
156156
157157 `MediaRegistry::insert_all` (`src/engine.rs:358-370`) evicts in first-insertion
158158 order and does not refresh position on re-registration. One registry of 2048
@@ -161,7 +161,7 @@ loaded in a room that is still open are the first evicted, and fetching them
161161 then fails with `MediaUnknown`. Closed timelines never release their entries,
162162 so they compete with open ones.
163163
164-### C14. Smaller correctness issues. OPEN
164+### C14. Smaller correctness issues. mostly done. `open_room` on an open room waits for q1 snapshots, the lagged watcher stays as is
165165
166166 - `CommandResultError::Failed` renders its `Display` through `Debug`
167167 (`src/transport.rs:840`), so users see a struct dump.
Modified file: examples/headless.rs+1 additions
@@ -16,6 +16,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
1616 username,
1717 password,
1818 device_display_name: Some("macltok headless example".into()),
19+ device_id: None,
1920 },
2021 StoreConfig::new(store_path, store_passphrase),
2122 )
Modified file: src/account.rs+5 additions
@@ -32,6 +32,8 @@ pub struct PasswordLogin {
3232 pub username: String,
3333 pub password: String,
3434 pub device_display_name: Option<String>,
35+ /// set after a soft logout so the store's keys still belong to us
36+ pub device_id: Option<OwnedDeviceId>,
3537 }
3638
3739 pub struct OAuthLogin {
@@ -180,6 +182,9 @@ impl Session {
180182 if let Some(name) = login.device_display_name.as_deref() {
181183 request = request.initial_device_display_name(name);
182184 }
185+ if let Some(device_id) = login.device_id.as_deref() {
186+ request = request.device_id(device_id.as_str());
187+ }
183188 request
184189 .send()
185190 .await
Modified file: src/encryption.rs+3 additions
@@ -365,6 +365,9 @@ impl VerificationController {
365365 "verification has not reached comparison",
366366 ));
367367 }
368+ sas.mismatch()
369+ .await
370+ .map_err(|source| CommandError::from_sdk("rejecting verification failed", source))?;
368371 // A peer completing the flow concurrently must not be reported as
369372 // rejected. A remote cancellation racing the send turns the SDK call
370373 // into a no-op success, so report it instead of a false success.
Modified file: src/engine.rs+310 additions−121 deletions
@@ -4,10 +4,8 @@ use std::{
44 future::Future,
55 io::Read as _,
66 panic::AssertUnwindSafe,
7- sync::{
8- Arc,
9- atomic::{AtomicBool, Ordering},
10- },
7+ sync::Arc,
8+ time::Duration,
119 };
1210
1311 use futures_util::{FutureExt, StreamExt};
@@ -154,6 +152,10 @@ type UploadWatchers = HashMap<
154152 watch::Receiver<Option<Result<(), CommandErrorKind>>>,
155153 ),
156154 >;
155+const SYNC_RESTART_MIN: Duration = Duration::from_secs(1);
156+const SYNC_RESTART_MAX: Duration = Duration::from_secs(60);
157+const SEND_QUEUE_RETRY: Duration = Duration::from_secs(5);
158+
157159 type MediaFlights = Arc<Mutex<HashMap<(MediaId, MediaSize), Vec<CommandId>>>>;
158160
159161 pub struct Engine {
@@ -181,6 +183,7 @@ pub struct Engine {
181183 global_account_gates: HashMap<String, oneshot::Receiver<()>>,
182184 room_account_gates: HashMap<(OwnedRoomId, String), oneshot::Receiver<()>>,
183185 media_flights: MediaFlights,
186+ stop: Arc<tokio::sync::Notify>,
184187 }
185188
186189 struct CommandJob<T> {
@@ -340,9 +343,11 @@ enum EngineActivity {
340343 RoomActor(Option<RoomActorEvent>),
341344 }
342345
346+/// least recently seen goes first. stale order slots are skipped by stamp rather than hunted down
343347 struct MediaRegistry {
344- entries: HashMap<MediaId, MediaRegistration>,
345- order: VecDeque<MediaId>,
348+ entries: HashMap<MediaId, (MediaRegistration, u64)>,
349+ order: VecDeque<(MediaId, u64)>,
350+ next_stamp: u64,
346351 limit: usize,
347352 }
348353
@@ -351,22 +356,45 @@ impl MediaRegistry {
351356 Self {
352357 entries: HashMap::new(),
353358 order: VecDeque::new(),
359+ next_stamp: 0,
354360 limit,
355361 }
356362 }
357363
364+ fn get(&self, id: &MediaId) -> Option<&MediaRegistration> {
365+ self.entries.get(id).map(|(media, _)| media)
366+ }
367+
358368 fn insert_all(&mut self, media: Vec<MediaRegistration>) {
359369 for media in media {
360- if !self.entries.contains_key(&media.id) {
361- self.order.push_back(media.id.clone());
362- }
363- self.entries.insert(media.id.clone(), media);
370+ let stamp = self.next_stamp;
371+ self.next_stamp += 1;
372+ self.order.push_back((media.id.clone(), stamp));
373+ self.entries.insert(media.id.clone(), (media, stamp));
364374 }
365375 while self.entries.len() > self.limit {
366- if let Some(id) = self.order.pop_front() {
376+ let Some((id, stamp)) = self.order.pop_front() else {
377+ break;
378+ };
379+ if self
380+ .entries
381+ .get(&id)
382+ .is_some_and(|(_, current)| *current == stamp)
383+ {
367384 self.entries.remove(&id);
368385 }
369386 }
387+ if self.order.len() > self.limit.saturating_mul(4) {
388+ let entries = &self.entries;
389+ self.order
390+ .retain(|(id, stamp)| entries.get(id).is_some_and(|(_, current)| current == stamp));
391+ }
392+ }
393+
394+ fn release(&mut self, scope: &MediaScope) {
395+ self.entries.retain(|id, _| !scope.owns(id));
396+ let entries = &self.entries;
397+ self.order.retain(|(id, _)| entries.contains_key(id));
370398 }
371399 }
372400
@@ -393,6 +421,18 @@ struct RoomActor {
393421 media: Arc<Mutex<MediaRegistry>>,
394422 initial_history_limit: u16,
395423 open_ids: Vec<CommandId>,
424+ background: JoinSet<(CommandId, Result<(), CommandError>)>,
425+ background_ids: HashMap<TaskId, CommandId>,
426+}
427+
428+type BackgroundResult =
429+ Result<(TaskId, (CommandId, Result<(), CommandError>)), tokio::task::JoinError>;
430+type BackgroundWork = std::pin::Pin<Box<dyn Future<Output = Result<(), CommandError>> + Send>>;
431+
432+/// network-bound work runs beside the actor so one slow redaction doesn't queue the room behind it
433+enum TimelineOutcome {
434+ Done(Result<(), CommandError>),
435+ Background(BackgroundWork),
396436 }
397437
398438 struct TimelineSession {
@@ -408,14 +448,12 @@ struct TimelineSession {
408448 pagination_worker: JoinHandle<()>,
409449 pagination_results: Option<mpsc::UnboundedSender<(CommandId, Result<(), CommandError>)>>,
410450 pagination_result_task: JoinHandle<()>,
411- reached_start: Arc<AtomicBool>,
412451 }
413452
414453 struct PaginationRequest {
415454 timeline: Arc<Timeline>,
416455 events: EventSender,
417456 timeline_id: TimelineId,
418- reached_start: Arc<AtomicBool>,
419457 limit: u16,
420458 command_id: Option<CommandId>,
421459 results: mpsc::UnboundedSender<(CommandId, Result<(), CommandError>)>,
@@ -491,7 +529,6 @@ impl TimelineSession {
491529 pagination_ids,
492530 pagination_results: Some(pagination_results),
493531 pagination_result_task,
494- reached_start: Arc::new(AtomicBool::new(false)),
495532 };
496533 if initial_history_limit > 0 {
497534 session.paginate(initial_history_limit, None).await;
@@ -522,7 +559,6 @@ impl TimelineSession {
522559 timeline: self.timeline.clone(),
523560 events: self.events.clone(),
524561 timeline_id: self.id.clone(),
525- reached_start: self.reached_start.clone(),
526562 limit,
527563 command_id,
528564 results: self
@@ -550,6 +586,7 @@ impl TimelineSession {
550586 }
551587
552588 async fn close(mut self, kind: CommandErrorKind, message: &'static str) {
589+ self.media.lock().await.release(&self.scope);
553590 self.pagination_worker.abort();
554591 let _ = (&mut self.pagination_worker).await;
555592 drop(self.pagination_results.take());
@@ -678,6 +715,7 @@ impl Engine {
678715 global_account_gates: HashMap::new(),
679716 room_account_gates: HashMap::new(),
680717 media_flights: Arc::new(Mutex::new(HashMap::new())),
718+ stop: Arc::new(tokio::sync::Notify::new()),
681719 },
682720 handle,
683721 events,
@@ -718,6 +756,7 @@ impl Engine {
718756 terminal_error = Some(EngineError::EventReceiverClosed);
719757 break;
720758 }
759+ () = self.stop.notified() => break,
721760 request = self.port.recv() => EngineActivity::Request(request.map(Box::new)),
722761 completed = self.tasks.join_next(), if !self.tasks.is_empty() => {
723762 EngineActivity::Task(completed)
@@ -825,6 +864,7 @@ impl Engine {
825864 fn spawn_logout(&mut self, id: CommandId) {
826865 let events = self.port.event_sender();
827866 let client = self.client.clone();
867+ let stop = self.stop.clone();
828868 self.tasks.spawn(
829869 TaskContext::Command {
830870 id,
@@ -841,6 +881,7 @@ impl Engine {
841881 let _ =
842882 emit_command_result(&events, id, CommandKind::Account, Ok(())).await;
843883 let _ = events.send(Event::LoggedOut).await;
884+ stop.notify_one();
844885 }
845886 Err(error) => {
846887 let _ = emit_command_result(&events, id, CommandKind::Account, Err(error))
@@ -1306,12 +1347,21 @@ impl Engine {
13061347 .take()
13071348 .expect("session watcher has not started");
13081349 let session_sender = self.port.event_sender();
1350+ let stop = self.stop.clone();
13091351 self.tasks
13101352 .spawn(TaskContext::Watcher("session change watcher"), async move {
13111353 loop {
13121354 let event = match session_changes.recv().await {
13131355 Ok(SessionChange::TokensRefreshed) => Event::SessionTokensChanged,
1314- Ok(SessionChange::UnknownToken(_)) => Event::SessionAccessRejected,
1356+ Ok(SessionChange::UnknownToken(data)) => {
1357+ let _ = session_sender
1358+ .send(Event::SessionAccessRejected {
1359+ soft_logout: data.soft_logout,
1360+ })
1361+ .await;
1362+ stop.notify_one();
1363+ break;
1364+ }
13151365 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
13161366 Event::SessionTokensChanged
13171367 }
@@ -1326,37 +1376,78 @@ impl Engine {
13261376 let mut sync_states = self.sync.state();
13271377 let state_sender = self.port.event_sender();
13281378 let connection_sender = self.port.connection_sender();
1379+ let sync = self.sync.clone();
1380+ let client = self.client.clone();
13291381 self.tasks.spawn(
13301382 TaskContext::Watcher("connection state watcher"),
13311383 async move {
1384+ let mut backoff = SYNC_RESTART_MIN;
13321385 while let Some(state) = sync_states.next().await {
1333- let state = match state {
1334- SyncState::Idle => ConnectionState::Idle,
1335- SyncState::Running => ConnectionState::Online,
1336- SyncState::Offline => ConnectionState::Offline,
1337- SyncState::Terminated => ConnectionState::Stopped,
1386+ let (state, restart) = match state {
1387+ SyncState::Idle => (ConnectionState::Idle, false),
1388+ SyncState::Running => {
1389+ backoff = SYNC_RESTART_MIN;
1390+ // the sdk parks a room queue on any send error and never unparks it
1391+ client.send_queue().set_enabled(true).await;
1392+ (ConnectionState::Online, false)
1393+ }
1394+ SyncState::Offline => (ConnectionState::Offline, false),
1395+ SyncState::Terminated => (ConnectionState::Offline, true),
13381396 SyncState::Error(error) => {
13391397 tracing::warn!(error = %error, "sync service reported an error");
1340- ConnectionState::Offline
1398+ (ConnectionState::Offline, true)
13411399 }
13421400 };
1343- connection_sender.send_replace(state);
1344- if state_sender
1345- .send(Event::ConnectionChanged(state))
1346- .await
1347- .is_err()
1348- {
1349- tracing::debug!(
1350- "connection watcher stopped because the event receiver closed"
1351- );
1352- break;
1401+ if *connection_sender.borrow() != state {
1402+ connection_sender.send_replace(state);
1403+ if state_sender
1404+ .send(Event::ConnectionChanged(state))
1405+ .await
1406+ .is_err()
1407+ {
1408+ break;
1409+ }
1410+ }
1411+ if restart {
1412+ tokio::time::sleep(backoff).await;
1413+ backoff = (backoff * 2).min(SYNC_RESTART_MAX);
1414+ sync.start().await;
13531415 }
13541416 }
1355- tracing::debug!("connection watcher stopped because the sync state stream ended");
13561417 },
13571418 );
13581419
13591420 let client = self.client.clone();
1421+ let online = self.port.connection_sender().subscribe();
1422+ self.tasks
1423+ .spawn(TaskContext::Watcher("send queue watcher"), async move {
1424+ let mut errors = client.send_queue().subscribe_errors();
1425+ let mut retries = JoinSet::new();
1426+ loop {
1427+ let error = tokio::select! {
1428+ error = errors.recv() => error,
1429+ Some(_) = retries.join_next(), if !retries.is_empty() => continue,
1430+ };
1431+ match error {
1432+ Ok(error) if error.is_recoverable => {
1433+ let client = client.clone();
1434+ let online = online.clone();
1435+ retries.spawn(async move {
1436+ tokio::time::sleep(SEND_QUEUE_RETRY).await;
1437+ if *online.borrow() == ConnectionState::Online
1438+ && let Some(room) = client.get_room(&error.room_id)
1439+ {
1440+ room.send_queue().set_enabled(true);
1441+ }
1442+ });
1443+ }
1444+ Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
1445+ Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1446+ }
1447+ }
1448+ });
1449+
1450+ let client = self.client.clone();
13601451 let encryption_sender = self.port.event_sender();
13611452 self.tasks.spawn(
13621453 TaskContext::Watcher("encryption state watcher"),
@@ -1506,13 +1597,6 @@ impl Engine {
15061597 }
15071598 Command::Room(RoomCommand::Open(room_id)) => self.open_room(id, room_id).await,
15081599 Command::Room(RoomCommand::Close(room_id)) => self.close_room(id, &room_id).await,
1509- Command::Room(RoomCommand::MarkRead {
1510- timeline_id,
1511- event_id,
1512- }) => {
1513- self.spawn_mark_read(id, timeline_id, event_id);
1514- Ok(true)
1515- }
15161600 Command::Room(RoomCommand::SetTyping { room_id, typing }) => {
15171601 self.spawn_typing(id, room_id, typing);
15181602 Ok(true)
@@ -1646,6 +1730,8 @@ impl Engine {
16461730 media: self.media.clone(),
16471731 initial_history_limit: self.initial_history_limit,
16481732 open_ids,
1733+ background: JoinSet::new(),
1734+ background_ids: HashMap::new(),
16491735 };
16501736 let current_user_id = self.client.user_id().map(ToOwned::to_owned);
16511737 self.rooms.insert(
@@ -1718,13 +1804,29 @@ impl Engine {
17181804 id: CommandId,
17191805 command: TimelineCommand,
17201806 ) -> Result<bool, CommandError> {
1721- let room_id = command.room_id();
17221807 let sender = self
17231808 .rooms
1724- .get(room_id)
1809+ .get(command.room_id())
17251810 .and_then(RoomActorState::sender)
1726- .ok_or_else(|| CommandError::new(CommandErrorKind::RoomNotOpen, "room is not open"))?;
1727- send_room_actor_request(sender, RoomActorRequest::Execute { id, command })?;
1811+ .cloned();
1812+ match (sender, command) {
1813+ (Some(sender), command) => {
1814+ send_room_actor_request(&sender, RoomActorRequest::Execute { id, command })?;
1815+ }
1816+ (
1817+ None,
1818+ TimelineCommand::MarkRead {
1819+ timeline_id,
1820+ event_id,
1821+ },
1822+ ) => self.spawn_mark_read(id, timeline_id, event_id),
1823+ (None, _) => {
1824+ return Err(CommandError::new(
1825+ CommandErrorKind::RoomNotOpen,
1826+ "room is not open",
1827+ ));
1828+ }
1829+ }
17281830 Ok(true)
17291831 }
17301832
@@ -2057,7 +2159,10 @@ impl Engine {
20572159 self.verification.lock().await.stop().await;
20582160 self.rooms.clear();
20592161 self.upload_watchers.clear();
2060- self.media.lock().await.entries.clear();
2162+ let mut media = self.media.lock().await;
2163+ media.entries.clear();
2164+ media.order.clear();
2165+ drop(media);
20612166 self.sync.stop().await;
20622167 self.port
20632168 .connection_sender()
@@ -2175,7 +2280,9 @@ async fn retry_send(
21752280 handle.unwedge().await.map_err(|source| {
21762281 CommandError::with_source(CommandErrorKind::Store, "retrying send failed", source)
21772282 })?;
2178- client.send_queue().set_enabled(true).await;
2283+ if let Some(room) = client.get_room(room_id) {
2284+ room.send_queue().set_enabled(true);
2285+ }
21792286 Ok(())
21802287 }
21812288
@@ -2475,11 +2582,14 @@ async fn execute_room_command(
24752582 .await
24762583 .map(|room| Some(room.room_id().to_owned()))
24772584 .map_err(|source| CommandError::from_sdk("creating room failed", source)),
2478- RoomCommand::CreateDirect(user_id) => client
2479- .create_dm(&user_id)
2480- .await
2481- .map(|room| Some(room.room_id().to_owned()))
2482- .map_err(|source| CommandError::from_sdk("creating direct room failed", source)),
2585+ RoomCommand::CreateDirect(user_id) => match client.get_dm_room(&user_id) {
2586+ Some(room) => Ok(Some(room.room_id().to_owned())),
2587+ None => client
2588+ .create_dm(&user_id)
2589+ .await
2590+ .map(|room| Some(room.room_id().to_owned()))
2591+ .map_err(|source| CommandError::from_sdk("creating direct room failed", source)),
2592+ },
24832593 _ => Err(CommandError::new(
24842594 CommandErrorKind::Other,
24852595 "unsupported room command",
@@ -2741,7 +2851,6 @@ async fn fetch_media(
27412851 let media = registry
27422852 .lock()
27432853 .await
2744- .entries
27452854 .get(&media_id)
27462855 .cloned()
27472856 .ok_or_else(|| {
@@ -2804,12 +2913,11 @@ async fn fetch_media(
28042913 "cached media exceeds the configured byte limit",
28052914 ));
28062915 }
2807- let bytes = maybe_decrypt_media(cached, &request, max_media_bytes)?;
28082916 events
28092917 .send(Event::MediaReady {
28102918 media_id,
28112919 size,
2812- bytes: Arc::from(bytes),
2920+ bytes: Arc::from(cached),
28132921 mimetype: mimetype.unwrap_or_else(|| "application/octet-stream".into()),
28142922 })
28152923 .await
@@ -2817,8 +2925,9 @@ async fn fetch_media(
28172925 return Ok(());
28182926 }
28192927 let bytes = download_bounded(&client, &request, max_media_bytes).await?;
2820- cache_media(&client, &request, &bytes).await;
2928+ // the sdk caches plaintext under the encrypted key, so we do too or nobody can read anything
28212929 let bytes = maybe_decrypt_media(bytes, &request, max_media_bytes)?;
2930+ cache_media(&client, &request, &bytes).await;
28222931 events
28232932 .send(Event::MediaReady {
28242933 media_id,
@@ -2927,24 +3036,39 @@ async fn download_bounded(
29273036 "local media is not in the media cache",
29283037 ));
29293038 }
2930- let http_request = media_http_request(client, request).await?;
2931- let http_client = client.http_client().clone();
2932- let request = http_client
2933- .execute(request_from_http(http_request)?)
2934- .await
2935- .map_err(|source| {
2936- CommandError::with_source(CommandErrorKind::Network, "media download failed", source)
2937- })?;
2938- let status = request.status();
2939- if status == matrix_sdk::reqwest::StatusCode::UNAUTHORIZED {
3039+ let mut refreshed = false;
3040+ let request = loop {
3041+ let http_request = media_http_request(client, request).await?;
3042+ let response = client
3043+ .http_client()
3044+ .execute(request_from_http(client, http_request)?)
3045+ .await
3046+ .map_err(|source| {
3047+ CommandError::with_source(
3048+ CommandErrorKind::Network,
3049+ "media download failed",
3050+ source,
3051+ )
3052+ })?;
3053+ if response.status() != matrix_sdk::reqwest::StatusCode::UNAUTHORIZED {
3054+ break response;
3055+ }
3056+ if refreshed
3057+ || client
3058+ .session_tokens()
3059+ .is_none_or(|tokens| tokens.refresh_token.is_none())
3060+ {
3061+ return Err(CommandError::new(
3062+ CommandErrorKind::Unauthenticated,
3063+ "media download was not authorized",
3064+ ));
3065+ }
29403066 client.refresh_access_token().await.map_err(|source| {
29413067 CommandError::from_refresh_token("refreshing media access token failed", source)
29423068 })?;
2943- return Err(CommandError::new(
2944- CommandErrorKind::Unauthenticated,
2945- "media download was not authorized",
2946- ));
2947- }
3069+ refreshed = true;
3070+ };
3071+ let status = request.status();
29483072 if !status.is_success() {
29493073 return Err(CommandError::new(
29503074 CommandErrorKind::MediaUnavailable,
@@ -3175,10 +3299,11 @@ async fn media_http_request(
31753299 }
31763300
31773301 fn request_from_http(
3302+ client: &Client,
31783303 request: http::Request<Vec<u8>>,
31793304 ) -> Result<matrix_sdk::reqwest::Request, CommandError> {
31803305 let (parts, _) = request.into_parts();
3181- let mut builder = matrix_sdk::reqwest::Client::new().get(parts.uri.to_string());
3306+ let mut builder = client.http_client().get(parts.uri.to_string());
31823307 for (name, value) in parts.headers.iter() {
31833308 builder = builder.header(name.as_str(), value.as_bytes());
31843309 }
@@ -3188,6 +3313,45 @@ fn request_from_http(
31883313 }
31893314
31903315 impl RoomActor {
3316+ async fn finish(
3317+ &mut self,
3318+ id: CommandId,
3319+ outcome: TimelineOutcome,
3320+ ) -> Result<(), SendEventError> {
3321+ match outcome {
3322+ TimelineOutcome::Done(result) => {
3323+ emit_command_result(&self.events, id, CommandKind::Timeline, result).await
3324+ }
3325+ TimelineOutcome::Background(work) => {
3326+ let task = self.background.spawn(async move { (id, work.await) });
3327+ self.background_ids.insert(task.id(), id);
3328+ Ok(())
3329+ }
3330+ }
3331+ }
3332+
3333+ async fn reap(&mut self, completed: BackgroundResult) -> Result<(), SendEventError> {
3334+ let (id, result) = match completed {
3335+ Ok((task_id, completed)) => {
3336+ self.background_ids.remove(&task_id);
3337+ completed
3338+ }
3339+ Err(error) => {
3340+ let Some(id) = self.background_ids.remove(&error.id()) else {
3341+ return Ok(());
3342+ };
3343+ (
3344+ id,
3345+ Err(CommandError::new(
3346+ CommandErrorKind::Other,
3347+ "timeline operation panicked",
3348+ )),
3349+ )
3350+ }
3351+ };
3352+ emit_command_result(&self.events, id, CommandKind::Timeline, result).await
3353+ }
3354+
31913355 async fn run(mut self, current_user_id: Option<ruma::OwnedUserId>) {
31923356 let timeline = match self
31933357 .room
@@ -3325,8 +3489,15 @@ impl RoomActor {
33253489 let thread_queue_cap = self.receiver.max_capacity();
33263490 let mut queued_thread_count = 0;
33273491
3492+ let mut room_info = self.room.subscribe_info();
33283493 'actor: loop {
33293494 tokio::select! {
3495+ Some(info) = room_info.next() => {
3496+ if matches!(info.state(), matrix_sdk::RoomState::Left | matrix_sdk::RoomState::Banned) {
3497+ tracing::debug!(room_id = %self.room_id, "room actor stopped because we left the room");
3498+ break 'actor;
3499+ }
3500+ }
33303501 request = self.receiver.recv() => {
33313502 let Some(request) = request else {
33323503 tracing::debug!(room_id = %self.room_id, "room actor stopped because its request channel closed");
@@ -3582,12 +3753,7 @@ impl RoomActor {
35823753 None,
35833754 )
35843755 .await;
3585- if emit_command_result(
3586- &self.events,
3587- id,
3588- CommandKind::Timeline,
3589- result,
3590- )
3756+ if self.finish(id, result)
35913757 .await
35923758 .is_err()
35933759 {
@@ -3607,12 +3773,7 @@ impl RoomActor {
36073773 thread_root.as_ref(),
36083774 )
36093775 .await;
3610- if emit_command_result(
3611- &self.events,
3612- id,
3613- CommandKind::Timeline,
3614- result,
3615- )
3776+ if self.finish(id, result)
36163777 .await
36173778 .is_err()
36183779 {
@@ -3744,12 +3905,7 @@ impl RoomActor {
37443905 thread_root.as_ref(),
37453906 )
37463907 .await;
3747- if emit_command_result(
3748- &self.events,
3749- id,
3750- CommandKind::Timeline,
3751- result,
3752- )
3908+ if self.finish(id, result)
37533909 .await
37543910 .is_err()
37553911 {
@@ -3822,6 +3978,11 @@ impl RoomActor {
38223978 None => {}
38233979 }
38243980 }
3981+ Some(completed) = self.background.join_next_with_id(), if !self.background.is_empty() => {
3982+ if self.reap(completed).await.is_err() {
3983+ break 'actor;
3984+ }
3985+ }
38253986 completed = room_tasks.join_next(), if !room_tasks.is_empty() => {
38263987 match completed {
38273988 Some(Err(error)) => {
@@ -3836,6 +3997,9 @@ impl RoomActor {
38363997 }
38373998 }
38383999
4000+ while let Some(completed) = self.background.join_next_with_id().await {
4001+ let _ = self.reap(completed).await;
4002+ }
38394003 drop(typing_guard);
38404004 room_tasks.abort_all();
38414005 while room_tasks.join_next().await.is_some() {}
@@ -3890,15 +4054,66 @@ async fn execute_timeline_command(
38904054 session: &mut TimelineSession,
38914055 command: TimelineCommand,
38924056 thread_root: Option<&OwnedEventId>,
4057+) -> TimelineOutcome {
4058+ let timeline = session.timeline.clone();
4059+ let work: BackgroundWork = match command {
4060+ TimelineCommand::Redact {
4061+ event_id, reason, ..
4062+ } => Box::pin(async move {
4063+ timeline
4064+ .redact(&TimelineEventItemId::EventId(event_id), reason.as_deref())
4065+ .await
4066+ .map_err(timeline_error)
4067+ }),
4068+ TimelineCommand::ToggleReaction { event_id, key, .. } => Box::pin(async move {
4069+ timeline
4070+ .toggle_reaction(&TimelineEventItemId::EventId(event_id), &key)
4071+ .await
4072+ .map(|_| ())
4073+ .map_err(timeline_error)
4074+ }),
4075+ TimelineCommand::MarkRead { event_id, .. } => {
4076+ let thread = thread_root.is_some();
4077+ Box::pin(async move {
4078+ let receipt_failed =
4079+ |source| CommandError::from_sdk("sending read receipt failed", source);
4080+ timeline
4081+ .send_single_receipt(ReceiptType::Read, event_id.clone())
4082+ .await
4083+ .map_err(receipt_failed)?;
4084+ if !thread {
4085+ timeline
4086+ .send_single_receipt(ReceiptType::FullyRead, event_id)
4087+ .await
4088+ .map_err(receipt_failed)?;
4089+ }
4090+ Ok(())
4091+ })
4092+ }
4093+ command => {
4094+ return TimelineOutcome::Done(
4095+ execute_serial_timeline_command(session, command, thread_root).await,
4096+ );
4097+ }
4098+ };
4099+ TimelineOutcome::Background(work)
4100+}
4101+
4102+async fn execute_serial_timeline_command(
4103+ session: &mut TimelineSession,
4104+ command: TimelineCommand,
4105+ thread_root: Option<&OwnedEventId>,
38934106 ) -> Result<(), CommandError> {
38944107 let timeline = &session.timeline;
38954108 match command {
3896- TimelineCommand::OpenThread { .. } | TimelineCommand::CloseThread { .. } => {
3897- Err(CommandError::new(
3898- CommandErrorKind::Other,
3899- "thread lifecycle command was misrouted",
3900- ))
3901- }
4109+ TimelineCommand::OpenThread { .. }
4110+ | TimelineCommand::CloseThread { .. }
4111+ | TimelineCommand::Redact { .. }
4112+ | TimelineCommand::ToggleReaction { .. }
4113+ | TimelineCommand::MarkRead { .. } => Err(CommandError::new(
4114+ CommandErrorKind::Other,
4115+ "timeline command was misrouted",
4116+ )),
39024117 TimelineCommand::Resync { .. } => session.resync().await.map_err(|_| {
39034118 CommandError::new(
39044119 CommandErrorKind::Timeline,
@@ -4009,17 +4224,6 @@ async fn execute_timeline_command(
40094224 .await
40104225 .map_err(timeline_error)
40114226 }
4012- TimelineCommand::Redact {
4013- event_id, reason, ..
4014- } => timeline
4015- .redact(&TimelineEventItemId::EventId(event_id), reason.as_deref())
4016- .await
4017- .map_err(timeline_error),
4018- TimelineCommand::ToggleReaction { event_id, key, .. } => timeline
4019- .toggle_reaction(&TimelineEventItemId::EventId(event_id), &key)
4020- .await
4021- .map(|_| ())
4022- .map_err(timeline_error),
40234227 }
40244228 }
40254229
@@ -4051,24 +4255,10 @@ async fn run_pagination(request: PaginationRequest) {
40514255 timeline,
40524256 events,
40534257 timeline_id,
4054- reached_start,
40554258 limit,
40564259 command_id,
40574260 results,
40584261 } = request;
4059- // One worker per timeline preserves pagination order without accumulating waiting tasks.
4060- if reached_start.load(Ordering::Acquire) {
4061- let _ = events
4062- .send(Event::HistoryChanged {
4063- timeline_id,
4064- state: HistoryState::StartReached,
4065- })
4066- .await;
4067- if let Some(id) = command_id {
4068- let _ = results.send((id, Ok(())));
4069- }
4070- return;
4071- }
40724262 if events
40734263 .send(Event::HistoryChanged {
40744264 timeline_id: timeline_id.clone(),
@@ -4094,7 +4284,6 @@ async fn run_pagination(request: PaginationRequest) {
40944284 .map_err(timeline_error);
40954285 match result {
40964286 Ok(at_start) => {
4097- reached_start.store(at_start, Ordering::Release);
40984287 let _ = events
40994288 .send(Event::HistoryChanged {
41004289 timeline_id,
Modified file: src/model.rs+19 additions−10 deletions
@@ -80,6 +80,7 @@ pub enum RoomCommand {
8080 level: i64,
8181 },
8282 Create(CreateRoomOptions),
83+ /// hands back the existing dm if there is one
8384 CreateDirect(OwnedUserId),
8485 CancelSend {
8586 room_id: OwnedRoomId,
@@ -89,10 +90,6 @@ pub enum RoomCommand {
8990 room_id: OwnedRoomId,
9091 transaction_id: OwnedTransactionId,
9192 },
92- MarkRead {
93- timeline_id: TimelineId,
94- event_id: OwnedEventId,
95- },
9693 SetTyping {
9794 room_id: OwnedRoomId,
9895 typing: bool,
@@ -167,6 +164,11 @@ pub enum TimelineCommand {
167164 event_id: OwnedEventId,
168165 key: String,
169166 },
167+ /// works on a closed room too, it just can't refuse to move the marker backwards
168+ MarkRead {
169+ timeline_id: TimelineId,
170+ event_id: OwnedEventId,
171+ },
170172 }
171173
172174 impl TimelineCommand {
@@ -179,7 +181,8 @@ impl TimelineCommand {
179181 | Self::Send { timeline_id, .. }
180182 | Self::Edit { timeline_id, .. }
181183 | Self::Redact { timeline_id, .. }
182- | Self::ToggleReaction { timeline_id, .. } => timeline_id.room_id(),
184+ | Self::ToggleReaction { timeline_id, .. }
185+ | Self::MarkRead { timeline_id, .. } => timeline_id.room_id(),
183186 }
184187 }
185188
@@ -192,7 +195,8 @@ impl TimelineCommand {
192195 | Self::Send { timeline_id, .. }
193196 | Self::Edit { timeline_id, .. }
194197 | Self::Redact { timeline_id, .. }
195- | Self::ToggleReaction { timeline_id, .. } => Some(timeline_id),
198+ | Self::ToggleReaction { timeline_id, .. }
199+ | Self::MarkRead { timeline_id, .. } => Some(timeline_id),
196200 }
197201 }
198202 }
@@ -578,8 +582,12 @@ pub enum Event {
578582 /// Refresh tokens changed. Persist a fresh `ClientHandle::session_record()`
579583 /// atomically; a record captured before this event may no longer restore.
580584 SessionTokensChanged,
581- /// Authentication was rejected. Remove the saved session record and store.
582- SessionAccessRejected,
585+ /// the server stopped honouring our token and the engine stops. on a soft
586+ /// logout keep the store and log in again with the same device, otherwise
587+ /// the record and the store are both dead
588+ SessionAccessRejected {
589+ soft_logout: bool,
590+ },
583591 NotificationSettingsChanged,
584592 VerificationTargetChanged(VerificationTarget),
585593 InteractiveAuthRequired {
@@ -637,10 +645,11 @@ pub enum Event {
637645 },
638646 /// Terminal failure correlated by `CommandFailure::id`.
639647 CommandFailed(CommandFailure),
640- /// Explicit logout completed; delete the saved authentication record.
648+ /// logout went through and the engine stops. delete the record and the store
641649 LoggedOut,
642650 Failed(EngineFailure),
643- /// Engine stopped without logging out; retain saved credentials for restore.
651+ /// always the last event. keep the saved record unless `LoggedOut` or a
652+ /// hard `SessionAccessRejected` came first. commands still in flight get no result
644653 Stopped,
645654 }
646655
Modified file: src/normalise/media.rs+6 additions
@@ -31,6 +31,12 @@ impl MediaScope {
3131 nonce: NEXT_MEDIA_SCOPE.fetch_add(1, Ordering::Relaxed),
3232 }
3333 }
34+
35+ pub(crate) fn owns(&self, id: &MediaId) -> bool {
36+ id.as_str()
37+ .split_once('#')
38+ .is_some_and(|(nonce, _)| nonce.parse() == Ok(self.nonce))
39+ }
3440 }
3541
3642 #[derive(Clone)]
Modified file: src/transport.rs+1 additions−1 deletions
@@ -837,7 +837,7 @@ pub enum CommandResultError {
837837 Send(#[from] SendCommandError),
838838 #[error("the engine stopped before the operation completed")]
839839 Closed,
840- #[error("{0:?}")]
840+ #[error("{}", .0.message)]
841841 Failed(crate::CommandFailure),
842842 }
843843
Modified file: tests/account.rs+1 additions
@@ -31,6 +31,7 @@ async fn password_session_round_trips_through_an_encrypted_store() {
3131 username: "example".into(),
3232 password: "wordpass".into(),
3333 device_display_name: Some("macltok test".into()),
34+ device_id: None,
3435 },
3536 StoreConfig::new(&store_path, "test store passphrase"),
3637 )
Modified file: tests/media.rs+50 additions−1 deletions
@@ -6,11 +6,12 @@ use macltok::{
66 TimelineDiff, TimelineItem, UploadDetails, UploadThumbnail,
77 };
88 use matrix_sdk::{
9+ media::{MediaFormat, MediaRequestParameters},
910 ruma::{
1011 api::client::sync::sync_events::v5,
1112 event_id,
1213 events::room::{
13- MediaSource,
14+ EncryptedFile, MediaSource,
1415 message::{
1516 AudioMessageEventContent, FileInfo, FileMessageEventContent,
1617 ImageMessageEventContent, MessageType, RoomMessageEventContent,
@@ -55,6 +56,54 @@ async fn full_size_uses_full_source() {
5556 }
5657
5758 #[tokio::test]
59+async fn encrypted_media_is_cached_as_plaintext_like_the_sdk_does() {
60+ use std::io::Read as _;
61+
62+ let plaintext = b"very secret image".to_vec();
63+ let mut cursor = std::io::Cursor::new(plaintext.clone());
64+ let mut encryptor = matrix_sdk_crypto::AttachmentEncryptor::new(&mut cursor);
65+ let mut ciphertext = Vec::new();
66+ encryptor.read_to_end(&mut ciphertext).unwrap();
67+ let keys = encryptor.finish();
68+ let file = EncryptedFile::new(
69+ mxc_uri!("mxc://localhost/secret").to_owned(),
70+ keys.encryption_info,
71+ keys.hashes,
72+ );
73+ let source = MediaSource::Encrypted(Box::new(file.clone()));
74+ let mut harness = start(RoomMessageEventContent::new(MessageType::Image(
75+ ImageMessageEventContent::encrypted("secret.png".to_owned(), file),
76+ )))
77+ .await;
78+ Mock::given(method("GET"))
79+ .and(path_regex("/download/localhost/secret$"))
80+ .respond_with(ResponseTemplate::new(200).set_body_bytes(ciphertext))
81+ .mount(harness.server.server())
82+ .await;
83+
84+ let from_sdk = harness
85+ .client
86+ .media()
87+ .get_media_content(
88+ &MediaRequestParameters {
89+ source,
90+ format: MediaFormat::File,
91+ },
92+ true,
93+ )
94+ .await
95+ .unwrap();
96+ assert_eq!(from_sdk, plaintext);
97+ assert_eq!(
98+ &*fetch_ready(&mut harness, MediaSize::Full).await,
99+ &plaintext[..]
100+ );
101+ assert_eq!(media_paths(&harness.server).await.len(), 1);
102+
103+ stop(harness).await;
104+}
105+
106+#[tokio::test]
58107 async fn simultaneous_fetches_share_one_download() {
59108 let mut harness = start(plain_image()).await;
60109 Mock::given(method("GET"))
Modified file: tests/send_queue.rs+6 additions−2 deletions
@@ -95,7 +95,11 @@ async fn cancel_queued_attachment_by_pending_send_id() {
9595 async fn retry_send_wakes_paused_queue() {
9696 let (server, client, handle, mut events, running) = setup().await;
9797 let room_id = room_id!("!queue-controls:localhost");
98- client.send_queue().set_enabled(false).await;
98+ client
99+ .get_room(room_id)
100+ .unwrap()
101+ .send_queue()
102+ .set_enabled(false);
99103 server
100104 .mock_room_send()
101105 .body_matches_partial_json(serde_json::json!({ "body": "retry me" }))
@@ -110,7 +114,7 @@ async fn retry_send_wakes_paused_queue() {
110114 .await
111115 .unwrap();
112116 expect_success(&mut events, id, CommandKind::Room).await;
113- assert!(client.send_queue().is_enabled());
117+ assert!(client.get_room(room_id).unwrap().send_queue().is_enabled());
114118
115119 tokio::time::timeout(Duration::from_secs(5), async {
116120 loop {
Modified file: tests/state.rs+24 additions
@@ -83,6 +83,30 @@ async fn connection_watch_tracks_the_engine_lifecycle() {
8383 }
8484
8585 #[tokio::test]
86+async fn soft_logout_says_so_and_stops_the_engine() {
87+ let server = MatrixMockServer::new().await;
88+ server
89+ .mock_sliding_sync()
90+ .error_unknown_token(true)
91+ .mount()
92+ .await;
93+ let client = server.client_builder().build().await;
94+ let (engine, _handle, mut events) = Engine::from_client(
95+ client,
96+ EngineConfig::default().with_initial_history_limit(0),
97+ )
98+ .await
99+ .unwrap();
100+ let running = tokio::spawn(engine.run());
101+ receive_until(&mut events, |event| {
102+ matches!(event, Event::SessionAccessRejected { soft_logout: true })
103+ })
104+ .await;
105+ receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;
106+ running.await.unwrap().unwrap();
107+}
108+
109+#[tokio::test]
86110 async fn closing_the_event_stream_stops_an_idle_engine() {
87111 let server = MatrixMockServer::new().await;
88112 server