Commit fa9114fcf75f
tarball15 files changed+453-156
@@ -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
@@ -35,9 +35,14 @@ after its fresh `TimelineReset`; the reset precedes the command completion.
3535
With `sqlite`, persist the initial `Session::record()` separately from its3636
encrypted SQLite store. On `SessionTokensChanged`, obtain a new record from3737
`ClientHandle::session_record()` and replace the saved record atomically in38
-protected storage. Restore with the same store path and passphrase. Delete the39
-saved credentials after `LoggedOut` or `SessionAccessRejected`, but keep them40
-after `Stopped` so a restart can restore the session. The headless example is38
+protected storage. Restore with the same store path and passphrase.39
+40
+`LoggedOut` and `SessionAccessRejected` both stop the engine. after a logout or a41
+hard rejection the record and the store are dead, bin them. a soft logout wants42
+the store kept and a fresh login with `PasswordLogin::device_id` set to the old43
+device, or the e2ee keys go with it. a bare `Stopped` means keep everything. one44
+store directory per login, the sdk won't share a crypto store between devices.45
+The headless example is4146
login-only; it does not persist sessions across runs.4247
4348
## Dependency policy@@ -99,9 +99,10 @@ login, then consume `SessionTokensChanged` and atomically replace that saved
9999
record with `ClientHandle::session_record()`. A previously captured record may100100
hold an exhausted refresh token; failing to persist a rotation must be surfaced101101
rather than silently treating the stale record as safe to restore. Restore with102
-the same `StoreConfig` path and passphrase. Explicit logout and103
-`SessionAccessRejected` invalidate the saved record and store; `Stopped` alone104
-does not. Neither cookies nor application-specific secret storage belong in the102
+the same `StoreConfig` path and passphrase. logout and a rejected token both103
+stop the engine. logout and a hard rejection kill the record and the store, a104
+soft logout keeps the store for a same-device login, `Stopped` alone kills105
+nothing. Neither cookies nor application-specific secret storage belong in the105106
toolkit.106107
107108
## Notifications and room data@@ -14,7 +14,7 @@ pass. The sqlite and clippy runs are recorded at the end.
1414
1515
## C. Correctness1616
17
-### C1. `Verify(Reject)` never rejects. OPEN17
+### C1. `Verify(Reject)` never rejects. done1818
1919
`VerificationController::reject` (`src/encryption.rs:339-389`) validates the2020
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 test2424
exercises `Reject`.2525
26
-### C2. Sync dies permanently after `SyncState::Error`. OPEN26
+### C2. Sync dies permanently after `SyncState::Error`. done2727
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 report3939
`Stopped` only from engine shutdown.4040
41
-### C3. A room's send queue stays disabled after any send failure. OPEN41
+### C3. A room's send queue stays disabled after any send failure. done4242
4343
The SDK disables a room's queue after every send error, recoverable or not4444
(`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 keep5252
`RetrySend` for unwedging a single item.5353
54
-### C4. Own encrypted uploads cannot be fetched, and the media cache is split between two formats. OPEN54
+### 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 cache5555
5656
macltok writes ciphertext into the media store under the `Encrypted` source key5757
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 by6969
decrypting in a streaming fashion before the write.7070
71
-### C5. Media single-flight is poisoned by a leader panic. OPEN71
+### C5. Media single-flight is poisoned by a leader panic. folded into the media query rewrite under q17272
7373
In `spawn_media_fetch` (`src/engine.rs:1870-1911`) followers push their id and7474
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 future8080
so the entry always clears.8181
82
-### C6. Back pagination can stick at "start reached" forever. OPEN82
+### C6. Back pagination can stick at "start reached" forever. done, the sdk already remembers8383
8484
`reached_start` is a cached `AtomicBool` that is only ever set from a8585
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. OPEN95
+### C7. Soft logout destroys the store. done, `SessionAccessRejected { soft_logout }` and a same-device login9696
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 for103103
nothing.104104
105
-### C8. Logout and rejected access leave the engine running. OPEN105
+### C8. Logout and rejected access leave the engine running. done106106
107107
`spawn_logout` emits `LoggedOut` and returns (`src/engine.rs:825-852`). The108108
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. OPEN113
+### C9. `MarkRead` can move the read marker backwards and costs two requests. done, open rooms go through the timeline so stale receipts get dropped114114
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 single126126
`Room::send_multiple_receipts`.127127
128
-### C10. `create_direct` always creates a new room. OPEN128
+### C10. `create_direct` always creates a new room. done129129
130130
`Client::create_dm` never reuses an existing DM131131
(`matrix-sdk client/mod.rs:1830-1849`). Starting a chat with someone you132132
already talk to creates a duplicate every time. `Client::get_dm_room` exists133133
for the lookup (`client/mod.rs:1852`).134134
135
-### C11. Unregistered tasks swallow panics, contradicting the design doc. OPEN135
+### C11. Unregistered tasks swallow panics, contradicting the design doc. folded into q1 and the actor restructure136136
137137
The design doc says a watcher panic is terminal. But the timeline watcher138138
(`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 panic144144
in a single write kills the whole engine.145145
146
-### C12. The room actor blocks on network requests. OPEN146
+### C12. The room actor blocks on network requests. done, redact reactions and receipts run beside the actor. replies stay serial so sends keep their order147147
148148
Each room actor runs its commands serially and awaits each one. Most sends149149
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 later153153
send, paginate and thread command in that room behind one redaction.154154
155
-### C13. Media registry evicts media that is still on screen. OPEN155
+### C13. Media registry evicts media that is still on screen. done, lru by stamp and released on close156156
157157
`MediaRegistry::insert_all` (`src/engine.rs:358-370`) evicts in first-insertion158158
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. OPEN164
+### C14. Smaller correctness issues. mostly done. `open_room` on an open room waits for q1 snapshots, the lagged watcher stays as is165165
166166
- `CommandResultError::Failed` renders its `Display` through `Debug`167167
(`src/transport.rs:840`), so users see a struct dump.@@ -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
)@@ -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 us36
+ 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
request184189
.send()185190
.await@@ -365,6 +365,9 @@ impl VerificationController {
365365
"verification has not reached comparison",366366
));367367
}368
+ sas.mismatch()369
+ .await370
+ .map_err(|source| CommandError::from_sdk("rejecting verification failed", source))?;368371
// A peer completing the flow concurrently must not be reported as369372
// rejected. A remote cancellation racing the send turns the SDK call370373
// into a no-op success, so report it instead of a false success.@@ -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 down343347
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 self380
+ .entries381
+ .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.order390
+ .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 it433
+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.tasks13101352
.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_sender1358
+ .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::SessionTokensChanged13171367
}@@ -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 it1391
+ 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::Offline1398
+ (ConnectionState::Offline, true)13411399
}13421400
};1343
- connection_sender.send_replace(state);1344
- if state_sender1345
- .send(Event::ConnectionChanged(state))1346
- .await1347
- .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_sender1404
+ .send(Event::ConnectionChanged(state))1405
+ .await1406
+ .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.tasks1423
+ .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::Online1438
+ && 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 = self17231808
.rooms1724
- .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.port20632168
.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
.await24762583
.map(|room| Some(room.room_id().to_owned()))24772584
.map_err(|source| CommandError::from_sdk("creating room failed", source)),2478
- RoomCommand::CreateDirect(user_id) => client2479
- .create_dm(&user_id)2480
- .await2481
- .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 => client2588
+ .create_dm(&user_id)2589
+ .await2590
+ .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 = registry27422852
.lock()27432853
.await2744
- .entries27452854
.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
events28092917
.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 anything28212929
let bytes = maybe_decrypt_media(bytes, &request, max_media_bytes)?;2930
+ cache_media(&client, &request, &bytes).await;28222931
events28232932
.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_client2933
- .execute(request_from_http(http_request)?)2934
- .await2935
- .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 = client3043
+ .http_client()3044
+ .execute(request_from_http(client, http_request)?)3045
+ .await3046
+ .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 refreshed3057
+ || client3058
+ .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).await3324
+ }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
+ completed3338
+ }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).await3353
+ }3354
+31913355
async fn run(mut self, current_user_id: Option<ruma::OwnedUserId>) {31923356
let timeline = match self31933357
.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
.await35923758
.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
.await36173778
.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
.await37543910
.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
+ timeline4064
+ .redact(&TimelineEventItemId::EventId(event_id), reason.as_deref())4065
+ .await4066
+ .map_err(timeline_error)4067
+ }),4068
+ TimelineCommand::ToggleReaction { event_id, key, .. } => Box::pin(async move {4069
+ timeline4070
+ .toggle_reaction(&TimelineEventItemId::EventId(event_id), &key)4071
+ .await4072
+ .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
+ timeline4081
+ .send_single_receipt(ReceiptType::Read, event_id.clone())4082
+ .await4083
+ .map_err(receipt_failed)?;4084
+ if !thread {4085
+ timeline4086
+ .send_single_receipt(ReceiptType::FullyRead, event_id)4087
+ .await4088
+ .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
.await40104225
.map_err(timeline_error)40114226
}4012
- TimelineCommand::Redact {4013
- event_id, reason, ..4014
- } => timeline4015
- .redact(&TimelineEventItemId::EventId(event_id), reason.as_deref())4016
- .await4017
- .map_err(timeline_error),4018
- TimelineCommand::ToggleReaction { event_id, key, .. } => timeline4019
- .toggle_reaction(&TimelineEventItemId::EventId(event_id), &key)4020
- .await4021
- .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 _ = events4062
- .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 events40734263
.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 _ = events40994288
.send(Event::HistoryChanged {41004289
timeline_id,@@ -80,6 +80,7 @@ pub enum RoomCommand {
8080
level: i64,8181
},8282
Create(CreateRoomOptions),83
+ /// hands back the existing dm if there is one8384
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 backwards168
+ 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 soft586
+ /// logout keep the store and log in again with the same device, otherwise587
+ /// the record and the store are both dead588
+ 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 store641649
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 a652
+ /// hard `SessionAccessRejected` came first. commands still in flight get no result644653
Stopped,645654
}646655
@@ -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)]@@ -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
@@ -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
)@@ -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 = harness85
+ .client86
+ .media()87
+ .get_media_content(88
+ &MediaRequestParameters {89
+ source,90
+ format: MediaFormat::File,91
+ },92
+ true,93
+ )94
+ .await95
+ .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"))@@ -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
+ client99
+ .get_room(room_id)100
+ .unwrap()101
+ .send_queue()102
+ .set_enabled(false);99103
server100104
.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
.await111115
.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 {@@ -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
+ server89
+ .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
+ .await99
+ .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