atagen

macltok

public
Star0
Watch0

Commit f12377bce9b9

tarball

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

11 files changed+450-112
engine: keep the session record saved as tokens change

A refresh token works once, so a host that misses one save after SessionTokensChanged has lost the login, and every host had to get that right on a bounded stream it may be slow to drain. The Session now carries its SessionStore into the engine, which saves each rotation off the event stream before announcing it. The session changes are subscribed when the client is built, because the sdk's initialisation task can refresh during restore before any engine exists. Natively the sdk's save callback writes the record inside the refresh itself through SessionStore::save_blocking, which closes the crash window before the new token is used.
Modified file: README.md+9 additions−3 deletions
@@ -44,9 +44,15 @@ lands. stop applying, call `resync`, and pick up from the next `TimelineReset`
4444 with `sqlite` or `indexeddb`, a `SessionStore` holds one login's record and its
4545 store. `EncryptedSessionStore` seals the record with the store's passphrase, in a
4646 file you pick natively or an IndexedDB database beside the store in a browser.
47-logging in saves the first record and `Session::restore` loads it. on every
48-`SessionTokensChanged`, `save` a fresh `ClientHandle::session_record()` before
49-the next refresh, refresh tokens only work once
47+logging in saves the first record and `Session::restore` loads it. from then on
48+the engine the `Session` builds keeps it current. every token rotation is saved
49+before `SessionTokensChanged` goes out, and a save that fails says so with
50+`SessionSaveFailed`. natively `EncryptedSessionStore` writes from inside the
51+sdk's refresh, before the new refresh token is ever used, and a store of your
52+own gets the same by implementing `SessionStore::save_blocking`. in a browser
53+the save can only follow the refresh, so a tab killed in between wakes with a
54+spent token. `Session::without_persistence` hands the job back, and then it's
55+yours to save `ClientHandle::session_record()` on every `SessionTokensChanged`
5056
5157 `Engine::run` resolves to a `Termination` saying why it stopped, and
5258 `Termination::retain` says what to do about it. after `LoggedOut` or
Modified file: docs/design-decisions.md+11 additions−2 deletions
@@ -168,8 +168,17 @@ with an encrypted store, refresh tokens and threads. the store is sqlite
168168 natively and indexeddb in a browser. secret-bearing inputs don't derive `Clone`
169169
170170 a `SessionStore` owns one login's record and its store. login saves the first
171-record, `restore` loads it, and the caller saves `ClientHandle::session_record()`
172-on every `SessionTokensChanged`. `EncryptedSessionStore` seals the record with
171+record, `restore` loads it, and the engine a `Session` builds saves every token
172+rotation after that, since a refresh token works once and a host that missed one
173+save has lost the login. the session changes are subscribed the moment the
174+client exists, because the sdk can refresh during restore before any engine
175+does. a save runs off the event stream, so a full stream never holds it back,
176+and `SessionTokensChanged` only goes out once it has landed, or
177+`SessionSaveFailed` if it didn't. natively the sdk's save callback runs inside
178+the refresh, before the new token is used, and `save_blocking` writes there and
179+then. in a browser there's only the async save, so a tab that dies between a
180+refresh and its save wakes up holding a spent token. `without_persistence`
181+hands the job back to the host. `EncryptedSessionStore` seals the record with
173182 the sdk's `StoreCipher` under the store's passphrase and replaces it by rename,
174183 so a crash mid save leaves the old one. it keeps the record out of the sdk's
175184 state store because sqlite writes custom values there in the clear, and a
Modified file: examples/headless.rs+11 additions−9 deletions
@@ -1,3 +1,5 @@
1+use std::sync::Arc;
2+
13 use macltok::{
24 EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore, Retain, RoomMembership,
35 Session, SessionStore, StoreConfig, TimelineContent, TimelineDiff, TimelineId, TimelineItem,
@@ -8,15 +10,18 @@ use macltok::{
810 async fn main() -> Result<(), Box<dyn std::error::Error>> {
911 let store_path = std::path::PathBuf::from(required("MATRIX_STORE_PATH")?);
1012 let passphrase = required("MATRIX_STORE_PASSPHRASE")?;
11- let sessions = EncryptedSessionStore::file(
13+ let sessions = Arc::new(EncryptedSessionStore::file(
1214 store_path.with_extension("session"),
1315 StoreConfig::sqlite(&store_path, &passphrase),
14- );
15- let session = match Session::restore(&sessions).await? {
16+ ));
17+ let session = match Session::restore(sessions.clone()).await? {
1618 Some(Restore::Resumed(session)) => session,
1719 Some(Restore::SoftLoggedOut(soft)) => {
18- Session::login_password(soft.password_login(required("MATRIX_PASSWORD")?), &sessions)
19- .await?
20+ Session::login_password(
21+ soft.password_login(required("MATRIX_PASSWORD")?),
22+ sessions.clone(),
23+ )
24+ .await?
2025 }
2126 None => {
2227 Session::login_password(
@@ -26,7 +31,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
2631 required("MATRIX_PASSWORD")?,
2732 )
2833 .with_device_display_name("macltok headless example"),
29- &sessions,
34+ sessions.clone(),
3035 )
3136 .await?
3237 }
@@ -78,9 +83,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
7883 }
7984 print_messages(&items);
8085 }
81- Event::SessionTokensChanged => {
82- sessions.save(&handle.session_record()?).await?;
83- }
8486 Event::Stopped => break,
8587 _ => {}
8688 }
Modified file: src/account.rs+122 additions−15 deletions
@@ -1,9 +1,12 @@
11 #[cfg(feature = "sqlite")]
22 use std::path::PathBuf;
3+#[cfg(not(target_family = "wasm"))]
4+use std::sync::atomic::{AtomicBool, Ordering};
35 use std::{collections::HashMap, sync::Arc};
46
57 use matrix_sdk::{
6- Client, ClientBuilder, SendOutsideWasm, SessionMeta, SyncOutsideWasm, ThreadingSupport,
8+ Client, ClientBuilder, SendOutsideWasm, SessionChange, SessionMeta, SyncOutsideWasm,
9+ ThreadingSupport,
710 authentication::{
811 AuthSession,
912 matrix::MatrixSession,
@@ -18,14 +21,27 @@ use matrix_sdk::{
1821 };
1922 use serde::{Deserialize, Serialize};
2023 use thiserror::Error;
24+use tokio::sync::{broadcast, mpsc};
2125 use url::Url;
2226
2327 use crate::{
24- ClientHandle, Engine, EngineConfig, EngineError, EventStream, SessionStore, error::SourceError,
28+ ClientHandle, Engine, EngineConfig, EngineError, ErrorKind, EventStream, SessionStore,
29+ engine::Persistence, error::SourceError,
2530 };
2631
32+/// a logged in client and the `SessionStore` its record lives in. the engine it builds
33+/// saves every token rotation there, unless told `without_persistence`
2734 pub struct Session {
2835 client: Client,
36+ tracking: Tracking,
37+}
38+
39+/// taken as soon as the client exists, since the sdk can refresh before there's an engine
40+struct Tracking {
41+ session_changes: broadcast::Receiver<SessionChange>,
42+ persistence: Option<Persistence>,
43+ #[cfg(not(target_family = "wasm"))]
44+ inline: Arc<AtomicBool>,
2945 }
3046
3147 #[non_exhaustive]
@@ -127,6 +143,7 @@ pub enum OAuthApplicationType {
127143 pub struct PendingOAuthLogin {
128144 client: Client,
129145 sessions: Arc<dyn SessionStore>,
146+ tracking: Option<Tracking>,
130147 authorization_url: String,
131148 state: Option<CsrfToken>,
132149 }
@@ -164,7 +181,11 @@ impl PendingOAuthLogin {
164181 oauth.abort_login(&state).await;
165182 return Err(account_error("OAuth login", error));
166183 }
167- Session::saved(self.client.clone(), &*self.sessions).await
184+ let tracking = self
185+ .tracking
186+ .take()
187+ .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;
188+ Session::saved(self.client.clone(), &*self.sessions, tracking).await
168189 }
169190
170191 pub async fn abort(mut self) {
@@ -239,8 +260,8 @@ impl StoreConfig {
239260 }
240261 }
241262
242-/// Serializable authentication secrets. A `SessionStore` keeps it, and it must
243-/// be replaced when `Event::SessionTokensChanged` arrives.
263+/// Serializable authentication secrets. A `SessionStore` keeps it, and the engine
264+/// replaces it there on every token rotation.
244265 #[derive(Clone, Deserialize, Serialize)]
245266 pub struct SessionRecord {
246267 homeserver: String,
@@ -334,13 +355,14 @@ pub enum AccountError {
334355 impl Session {
335356 pub async fn login_password(
336357 login: PasswordLogin,
337- sessions: &dyn SessionStore,
358+ sessions: Arc<dyn SessionStore>,
338359 ) -> Result<Self, AccountError> {
339360 let client = matrix_client_builder(sessions.store())
340361 .server_name_or_homeserver_url(login.homeserver.trim())
341362 .build()
342363 .await
343364 .map_err(|error| account_error("building the Matrix client", error))?;
365+ let tracking = Tracking::start(&client, sessions.clone())?;
344366 let mut request = client
345367 .matrix_auth()
346368 .login_username(login.username.trim(), &login.password)
@@ -355,7 +377,7 @@ impl Session {
355377 .send()
356378 .await
357379 .map_err(|error| account_error("password login", error))?;
358- Self::saved(client, sessions).await
380+ Self::saved(client, &*sessions, tracking).await
359381 }
360382
361383 pub async fn begin_oauth(
@@ -371,6 +393,7 @@ impl Session {
371393 .build()
372394 .await
373395 .map_err(|error| account_error("building the Matrix client", error))?;
396+ let tracking = Tracking::start(&client, sessions.clone())?;
374397 let application_type = match login.application_type {
375398 OAuthApplicationType::Native => ApplicationType::Native,
376399 OAuthApplicationType::Web => ApplicationType::Web,
@@ -401,14 +424,15 @@ impl Session {
401424 Ok(PendingOAuthLogin {
402425 client,
403426 sessions,
427+ tracking: Some(tracking),
404428 authorization_url: authorization.url.to_string(),
405429 state: Some(authorization.state),
406430 })
407431 }
408432
409433 /// Restore the record `sessions` holds, or `None` when it holds none.
410- /// The caller must also handle refresh, rejection, and explicit logout.
411- pub async fn restore(sessions: &dyn SessionStore) -> Result<Option<Restore>, AccountError> {
434+ /// The caller must also handle rejection and explicit logout.
435+ pub async fn restore(sessions: Arc<dyn SessionStore>) -> Result<Option<Restore>, AccountError> {
412436 let Some(record) = sessions.load().await? else {
413437 return Ok(None);
414438 };
@@ -431,28 +455,111 @@ impl Session {
431455 .build()
432456 .await
433457 .map_err(|error| account_error("reopening the Matrix store", error))?;
458+ let tracking = Tracking::start(&client, sessions)?;
434459 client
435460 .restore_session(authentication)
436461 .await
437462 .map_err(|error| account_error("restoring the authenticated session", error))?;
438- Ok(Some(Restore::Resumed(Self { client })))
463+ Ok(Some(Restore::Resumed(Self { client, tracking })))
464+ }
465+
466+ /// leave the record to the host, which then saves `ClientHandle::session_record()`
467+ /// on every `SessionTokensChanged`
468+ #[must_use]
469+ pub fn without_persistence(mut self) -> Self {
470+ self.tracking.persistence = None;
471+ #[cfg(not(target_family = "wasm"))]
472+ self.tracking.inline.store(false, Ordering::Release);
473+ self
439474 }
440475
441476 pub async fn build(
442477 self,
443478 config: EngineConfig,
444479 ) -> Result<(Engine, ClientHandle, EventStream), AccountError> {
445- Engine::from_client(self.client, config)
446- .await
447- .map_err(AccountError::from)
480+ Engine::with_persistence(
481+ self.client,
482+ config,
483+ self.tracking.session_changes,
484+ self.tracking.persistence,
485+ )
486+ .await
487+ .map_err(AccountError::from)
448488 }
449489
450- async fn saved(client: Client, sessions: &dyn SessionStore) -> Result<Self, AccountError> {
490+ async fn saved(
491+ client: Client,
492+ sessions: &dyn SessionStore,
493+ tracking: Tracking,
494+ ) -> Result<Self, AccountError> {
451495 sessions.save(&session_record(&client)?).await?;
452- Ok(Self { client })
496+ Ok(Self { client, tracking })
497+ }
498+}
499+
500+impl Tracking {
501+ fn start(client: &Client, sessions: Arc<dyn SessionStore>) -> Result<Self, AccountError> {
502+ let session_changes = client.subscribe_to_session_changes();
503+ #[cfg_attr(target_family = "wasm", allow(unused_variables))]
504+ let (results, inline_results) = mpsc::unbounded_channel();
505+ #[cfg(not(target_family = "wasm"))]
506+ let inline = save_inline(client, sessions.clone(), results)?;
507+ Ok(Self {
508+ session_changes,
509+ persistence: Some(Persistence {
510+ save: Box::new(move |client| {
511+ let sessions = sessions.clone();
512+ Box::pin(async move {
513+ let record = session_record(&client).map_err(save_failed)?;
514+ sessions.save(&record).await.map_err(save_failed)
515+ })
516+ }),
517+ inline: inline_results,
518+ }),
519+ #[cfg(not(target_family = "wasm"))]
520+ inline,
521+ })
453522 }
454523 }
455524
525+/// the sdk calls this inside its refresh, after it has the new tokens and before it uses
526+/// them, so a store that writes there and then can't lose a refresh token to a crash
527+#[cfg(not(target_family = "wasm"))]
528+fn save_inline(
529+ client: &Client,
530+ sessions: Arc<dyn SessionStore>,
531+ results: mpsc::UnboundedSender<Result<(), crate::Error>>,
532+) -> Result<Arc<AtomicBool>, AccountError> {
533+ let enabled = Arc::new(AtomicBool::new(true));
534+ let saving = enabled.clone();
535+ client
536+ .set_session_callbacks(
537+ // only asked for with the cross process refresh lock, which we never enable
538+ Box::new(|client| {
539+ client
540+ .session_tokens()
541+ .ok_or_else(|| AccountError::MissingSession.into())
542+ }),
543+ Box::new(move |client| {
544+ if !saving.load(Ordering::Acquire) {
545+ return Ok(());
546+ }
547+ let record = session_record(&client)?;
548+ if let Some(result) = sessions.save_blocking(&record) {
549+ let _ = results.send(result.map_err(save_failed));
550+ }
551+ Ok(())
552+ }),
553+ )
554+ .map_err(|error| account_error("registering the session callbacks", error))?;
555+ Ok(enabled)
556+}
557+
558+fn save_failed(error: AccountError) -> crate::Error {
559+ let message = format!("saving the session record failed, {error}");
560+ crate::Error::with_source(ErrorKind::Store, message, error)
561+}
562+
456563 pub(crate) fn session_record(client: &Client) -> Result<SessionRecord, AccountError> {
457564 let authentication = match client.session().ok_or(AccountError::MissingSession)? {
458565 AuthSession::Matrix(session) => AuthenticationRecord::Matrix(session),
Modified file: src/engine/mod.rs+31 additions−6 deletions
@@ -3,6 +3,7 @@ pub(crate) mod executor;
33 mod media;
44 mod mutations;
55 mod room;
6+mod session;
67 mod timeline;
78 mod watchers;
89
@@ -21,6 +22,7 @@ use thiserror::Error as ThisError;
2122 use tokio::sync::{broadcast, mpsc, watch};
2223
2324 pub(crate) use media::MediaService;
25+pub(crate) use session::Persistence;
2426
2527 use self::{
2628 commands::Job,
@@ -226,6 +228,7 @@ struct Queues {
226228 notifications: mpsc::Receiver<Job<NotificationCommand>>,
227229 encryption: mpsc::Receiver<Job<EncryptionCommand>>,
228230 session_changes: broadcast::Receiver<SessionChange>,
231+ persistence: Option<Persistence>,
229232 upload_reports: mpsc::Receiver<watchers::RoomUpload>,
230233 }
231234
@@ -266,6 +269,18 @@ impl Engine {
266269 client: Client,
267270 config: EngineConfig,
268271 ) -> Result<(Self, ClientHandle, EventStream), EngineError> {
272+ let session_changes = client.subscribe_to_session_changes();
273+ Self::with_persistence(client, config, session_changes, None).await
274+ }
275+
276+ /// `session_changes` is taken when the client is built, since the sdk can refresh
277+ /// before an engine exists
278+ pub(crate) async fn with_persistence(
279+ client: Client,
280+ config: EngineConfig,
281+ session_changes: broadcast::Receiver<SessionChange>,
282+ persistence: Option<Persistence>,
283+ ) -> Result<(Self, ClientHandle, EventStream), EngineError> {
269284 if client.user_id().is_none() {
270285 return Err(EngineError::Unauthenticated);
271286 }
@@ -275,7 +290,6 @@ impl Engine {
275290 {
276291 return Err(EngineError::ZeroCapacity);
277292 }
278- let session_changes = client.subscribe_to_session_changes();
279293 let sync = Arc::new(
280294 SyncService::builder(client.clone())
281295 .with_offline_mode()
@@ -324,6 +338,7 @@ impl Engine {
324338 notifications: notification_queue,
325339 encryption: encryption_queue,
326340 session_changes,
341+ persistence,
327342 upload_reports: upload_report_queue,
328343 }),
329344 upload_reports,
@@ -422,7 +437,8 @@ impl Engine {
422437 self.verification_handler = Some(self.verification_handler(incoming));
423438 let client = &self.client;
424439 let events = || self.port.event_sender();
425- let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 11] = [
440+ let (notices, notice_queue) = mpsc::unbounded_channel();
441+ let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 12] = [
426442 (
427443 "upload tracker",
428444 Box::pin(watchers::uploads(
@@ -441,18 +457,27 @@ impl Engine {
441457 ),
442458 (
443459 "session change watcher",
444- Box::pin(watchers::session(
460+ Box::pin(session::session(
461+ client.clone(),
445462 queues.session_changes,
446- events(),
463+ queues.persistence,
464+ notices,
447465 client
448466 .device_id()
449467 .expect("authentication was checked during construction")
450468 .to_owned(),
451- self.stop.clone(),
452469 self.logouts.clone(),
453470 )),
454471 ),
455472 (
473+ "session notice watcher",
474+ Box::pin(session::session_notices(
475+ notice_queue,
476+ events(),
477+ self.stop.clone(),
478+ )),
479+ ),
480+ (
456481 "connection state watcher",
457482 Box::pin(watchers::connection(
458483 self.sync.state(),
@@ -560,7 +585,7 @@ impl Engine {
560585 false
561586 });
562587 if let Some(soft_logout) = rejected {
563- watchers::reject_session(&events, &stop, soft_logout).await;
588+ session::reject_session(&events, &stop, soft_logout).await;
564589 }
565590 logouts.send_modify(|logouts| logouts.in_flight -= 1);
566591 result.map_err(|source| Error::from_sdk("logout failed", source))
Added file: src/engine/session.rs+132 additions
@@ -0,0 +1,132 @@
1+use std::sync::Arc;
2+
3+use matrix_sdk::{BoxFuture, Client, SessionChange};
4+use ruma::OwnedDeviceId;
5+use tokio::sync::{
6+ broadcast::{self, error::RecvError},
7+ mpsc, watch,
8+};
9+
10+use super::Logouts;
11+use crate::{Error, Event, Termination, transport::EventSender};
12+
13+#[cfg(not(target_family = "wasm"))]
14+pub(crate) type SaveRecord = dyn Fn(Client) -> BoxFuture<'static, Result<(), Error>> + Send + Sync;
15+#[cfg(target_family = "wasm")]
16+pub(crate) type SaveRecord = dyn Fn(Client) -> BoxFuture<'static, Result<(), Error>>;
17+
18+/// how the engine keeps the saved record current, handed over by `Session::build`
19+pub(crate) struct Persistence {
20+ pub(crate) save: Box<SaveRecord>,
21+ /// what the sdk's own save callback made of each refresh, when the store could write
22+ /// there and then
23+ pub(crate) inline: mpsc::UnboundedReceiver<Result<(), Error>>,
24+}
25+
26+impl Persistence {
27+ async fn persist(&mut self, client: &Client) -> Result<(), Error> {
28+ let mut inline = None;
29+ while let Ok(result) = self.inline.try_recv() {
30+ inline = Some(result);
31+ }
32+ if let Some(Ok(())) = inline {
33+ return Ok(());
34+ }
35+ // an inline save for a newer refresh can land while this one writes, so go again
36+ // until what's on disk is what the client holds
37+ loop {
38+ let tokens = client.session_tokens();
39+ (self.save)(client.clone()).await?;
40+ if client.session_tokens() == tokens {
41+ return Ok(());
42+ }
43+ }
44+ }
45+}
46+
47+pub(super) enum Notice {
48+ Event(Event),
49+ Rejected(Option<OwnedDeviceId>),
50+}
51+
52+/// saves and notices are split, so a full event stream never holds a save back
53+pub(super) async fn session(
54+ client: Client,
55+ mut changes: broadcast::Receiver<SessionChange>,
56+ mut persistence: Option<Persistence>,
57+ notices: mpsc::UnboundedSender<Notice>,
58+ device_id: OwnedDeviceId,
59+ logouts: Arc<watch::Sender<Logouts>>,
60+) {
61+ loop {
62+ let event = match changes.recv().await {
63+ // a missed refresh costs one needless save, a missed rejection comes round again
64+ Ok(SessionChange::TokensRefreshed) | Err(RecvError::Lagged(_)) => {
65+ match persistence.as_mut() {
66+ None => Event::SessionTokensChanged,
67+ Some(persistence) => match persistence.persist(&client).await {
68+ Ok(()) => Event::SessionTokensChanged,
69+ Err(error) => Event::SessionSaveFailed {
70+ message: error.message,
71+ },
72+ },
73+ }
74+ }
75+ Ok(SessionChange::UnknownToken(data)) => {
76+ let soft_logout = data.soft_logout.then_some(device_id);
77+ let mut held = false;
78+ // our own logout revokes the token, so a rejection meanwhile is its doing
79+ logouts.send_if_modified(|logouts| {
80+ held = logouts.in_flight > 0;
81+ if held {
82+ logouts.rejected = Some(soft_logout.clone());
83+ }
84+ held
85+ });
86+ if !held {
87+ let _ = notices.send(Notice::Rejected(soft_logout));
88+ }
89+ return;
90+ }
91+ Err(RecvError::Closed) => return,
92+ };
93+ if notices.send(Notice::Event(event)).is_err() {
94+ return;
95+ }
96+ }
97+}
98+
99+pub(super) async fn session_notices(
100+ mut notices: mpsc::UnboundedReceiver<Notice>,
101+ events: EventSender,
102+ stop: mpsc::UnboundedSender<Termination>,
103+) {
104+ while let Some(notice) = notices.recv().await {
105+ match notice {
106+ Notice::Event(event) => {
107+ if events.send(event).await.is_err() {
108+ return;
109+ }
110+ }
111+ Notice::Rejected(soft_logout) => {
112+ return reject_session(&events, &stop, soft_logout).await;
113+ }
114+ }
115+ }
116+}
117+
118+pub(super) async fn reject_session(
119+ events: &EventSender,
120+ stop: &mpsc::UnboundedSender<Termination>,
121+ soft_logout: Option<OwnedDeviceId>,
122+) {
123+ let _ = events
124+ .send(Event::SessionAccessRejected {
125+ soft_logout: soft_logout.clone(),
126+ })
127+ .await;
128+ let _ = stop.send(match soft_logout {
129+ Some(device_id) => Termination::SoftLoggedOut { device_id },
130+ None => Termination::AccessRevoked,
131+ });
132+}
Modified file: src/engine/watchers.rs+8 additions−61 deletions
@@ -2,18 +2,18 @@ use std::{collections::HashSet, sync::Arc, time::Duration};
22
33 use futures_util::StreamExt;
44 use matrix_sdk::{
5- Client, Room, SessionChange,
5+ Client, Room,
66 ruma::events::{AnyMessageLikeEventContent, room::message::MessageType},
77 send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendQueueUpdate},
88 };
99 use matrix_sdk_ui::sync_service::{State as SyncState, SyncService};
10-use ruma::{OwnedDeviceId, OwnedRoomId, OwnedTransactionId, time::Instant};
10+use ruma::{OwnedRoomId, OwnedTransactionId, time::Instant};
1111 use tokio::sync::{broadcast::error::RecvError, mpsc, watch};
1212
13-use super::{Logouts, executor::TaskSet};
13+use super::executor::TaskSet;
1414 use crate::{
15- ConnectionState, Event, Termination, UploadOutcome, encryption::encryption_state,
16- error::classify_sdk_error, transport::EventSender,
15+ ConnectionState, Event, UploadOutcome, encryption::encryption_state, error::classify_sdk_error,
16+ transport::EventSender,
1717 };
1818
1919 const SYNC_RESTART_MIN: Duration = Duration::from_secs(1);
@@ -32,58 +32,6 @@ pub(super) async fn room_subscriptions(
3232 }
3333 }
3434
35-pub(super) async fn session(
36- mut changes: tokio::sync::broadcast::Receiver<SessionChange>,
37- events: EventSender,
38- device_id: OwnedDeviceId,
39- stop: mpsc::UnboundedSender<Termination>,
40- logouts: Arc<watch::Sender<Logouts>>,
41-) {
42- loop {
43- let event = match changes.recv().await {
44- Ok(SessionChange::TokensRefreshed) => Event::SessionTokensChanged,
45- Ok(SessionChange::UnknownToken(data)) => {
46- let soft_logout = data.soft_logout.then_some(device_id);
47- let mut held = false;
48- // our own logout revokes the token, so a rejection meanwhile is its doing
49- logouts.send_if_modified(|logouts| {
50- held = logouts.in_flight > 0;
51- if held {
52- logouts.rejected = Some(soft_logout.clone());
53- }
54- held
55- });
56- if !held {
57- reject_session(&events, &stop, soft_logout).await;
58- }
59- return;
60- }
61- // a missed refresh costs one needless save, a missed rejection comes round again
62- Err(RecvError::Lagged(_)) => Event::SessionTokensChanged,
63- Err(RecvError::Closed) => return,
64- };
65- if events.send(event).await.is_err() {
66- return;
67- }
68- }
69-}
70-
71-pub(super) async fn reject_session(
72- events: &EventSender,
73- stop: &mpsc::UnboundedSender<Termination>,
74- soft_logout: Option<OwnedDeviceId>,
75-) {
76- let _ = events
77- .send(Event::SessionAccessRejected {
78- soft_logout: soft_logout.clone(),
79- })
80- .await;
81- let _ = stop.send(match soft_logout {
82- Some(device_id) => Termination::SoftLoggedOut { device_id },
83- None => Termination::AccessRevoked,
84- });
85-}
86-
8735 /// `states` must be taken before sync starts, a late subscriber never hears the transition it missed
8836 pub(super) async fn connection(
8937 mut states: impl futures_util::Stream<Item = SyncState> + Unpin,
@@ -202,10 +150,9 @@ pub(super) async fn room_uploads(
202150 (room_id.clone(), transaction_id),
203151 UploadOutcome::Sent { event_id },
204152 ),
205- Ok(RoomSendQueueUpdate::CancelledLocalEvent { transaction_id }) => RoomUpload::Ended(
206- (room_id.clone(), transaction_id),
207- UploadOutcome::Cancelled,
208- ),
153+ Ok(RoomSendQueueUpdate::CancelledLocalEvent { transaction_id }) => {
154+ RoomUpload::Ended((room_id.clone(), transaction_id), UploadOutcome::Cancelled)
155+ }
209156 Ok(_) => continue,
210157 Err(RecvError::Lagged(_)) => RoomUpload::Lagged,
211158 Err(RecvError::Closed) => return,
Modified file: src/model.rs+7 additions−2 deletions
@@ -634,9 +634,14 @@ pub enum Event {
634634 Authenticated {
635635 user_id: OwnedUserId,
636636 },
637- /// Refresh tokens changed. Persist a fresh `ClientHandle::session_record()`
638- /// atomically; a record captured before this event may no longer restore.
637+ /// the tokens rotated. with a `SessionStore` the record is already saved by now,
638+ /// without one persist a fresh `ClientHandle::session_record()` atomically
639639 SessionTokensChanged,
640+ /// the rotated tokens didn't reach the `SessionStore`, so the saved record may not
641+ /// restore. the engine tries again on the next rotation
642+ SessionSaveFailed {
643+ message: String,
644+ },
640645 /// the server stopped honouring our token and the engine stops. a soft logout
641646 /// names the device to log in again as, keeping the store. otherwise the record
642647 /// and the store are both dead
Modified file: src/session_store.rs+40 additions−4 deletions
@@ -22,6 +22,14 @@ pub trait SessionStore: SendOutsideWasm + SyncOutsideWasm {
2222 /// replace the saved record, all or nothing
2323 fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>>;
2424
25+ /// `save`, but without awaiting. the sdk calls it from inside a refresh, before the new
26+ /// refresh token is ever used, so a store that can write synchronously closes the gap
27+ /// a crash would otherwise lose the login through. `None` leaves the refresh to `save`
28+ #[cfg(not(target_family = "wasm"))]
29+ fn save_blocking(&self, _record: &SessionRecord) -> Option<Result<(), AccountError>> {
30+ None
31+ }
32+
2533 /// swap the record for one that only says who to log in again as, keeping the store.
2634 /// `Session::restore` then answers `Restore::SoftLoggedOut`, even after a restart
2735 fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>>;
@@ -113,14 +121,33 @@ impl EncryptedSessionStore {
113121 sealer: &mut Option<Sealer>,
114122 record: &SessionRecord,
115123 ) -> Result<(), AccountError> {
124+ let sealed = self.seal(sealer, record)?;
125+ backend::write(&self.record, &sealed)
126+ .await
127+ .map_err(|error| account_error("writing the session record", error))
128+ }
129+
130+ fn seal(
131+ &self,
132+ sealer: &mut Option<Sealer>,
133+ record: &SessionRecord,
134+ ) -> Result<Vec<u8>, AccountError> {
116135 let sealer = match sealer {
117136 Some(sealer) => sealer,
118137 empty => empty.insert(Sealer::new(self.store.passphrase())?),
119138 };
120- let sealed = sealer.seal(record)?;
121- backend::write(&self.record, &sealed)
122- .await
123- .map_err(|error| account_error("writing the session record", error))
139+ sealer.seal(record)
140+ }
141+
142+ /// the lock is only ever held around one small write, so losing the race to it just
143+ /// hands this refresh to the async save
144+ #[cfg(feature = "sqlite")]
145+ fn save_now(&self, record: &SessionRecord) -> Option<Result<(), AccountError>> {
146+ let mut sealer = self.sealer.try_lock().ok()?;
147+ Some(self.seal(&mut sealer, record).and_then(|sealed| {
148+ backend::write_blocking(&self.record, &sealed)
149+ .map_err(|error| account_error("writing the session record", error))
150+ }))
124151 }
125152 }
126153
@@ -137,6 +164,11 @@ impl SessionStore for EncryptedSessionStore {
137164 Box::pin(self.save_record(record))
138165 }
139166
167+ #[cfg(feature = "sqlite")]
168+ fn save_blocking(&self, record: &SessionRecord) -> Option<Result<(), AccountError>> {
169+ self.save_now(record)
170+ }
171+
140172 fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>> {
141173 Box::pin(self.soft_log_out_record())
142174 }
@@ -211,6 +243,10 @@ mod backend {
211243 }
212244
213245 pub(super) async fn write(path: &Path, sealed: &[u8]) -> io::Result<()> {
246+ write_blocking(path, sealed)
247+ }
248+
249+ pub(super) fn write_blocking(path: &Path, sealed: &[u8]) -> io::Result<()> {
214250 let mut temporary = path.as_os_str().to_owned();
215251 temporary.push(".tmp");
216252 let temporary = PathBuf::from(temporary);
Modified file: src/transport.rs+2 additions−1 deletions
@@ -75,7 +75,8 @@ impl Future for Pending {
7575 }
7676
7777 impl ClientHandle {
78- /// current credentials. persist this atomically on `Event::SessionTokensChanged`, it holds secrets
78+ /// current credentials, it holds secrets. the engine saves it for you unless the
79+ /// session was built `without_persistence`
7980 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
8081 pub fn session_record(&self) -> Result<crate::SessionRecord, crate::AccountError> {
8182 crate::account::session_record(&self.client)
Modified file: tests/account.rs+77 additions−9 deletions
@@ -1,7 +1,7 @@
11 #![cfg(feature = "sqlite")]
22 mod support;
33
4-use std::{path::PathBuf, time::SystemTime};
4+use std::{path::PathBuf, sync::Arc, time::SystemTime};
55
66 use macltok::{
77 EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore, Session, SessionRecord,
@@ -37,7 +37,7 @@ async fn password_session_round_trips_and_discards_with_its_store() {
3737 let session = Session::login_password(
3838 PasswordLogin::new(server.uri(), "example", "wordpass")
3939 .with_device_display_name("macltok test"),
40- &sessions(),
40+ Arc::new(sessions()),
4141 )
4242 .await
4343 .unwrap();
@@ -56,8 +56,8 @@ async fn password_session_round_trips_and_discards_with_its_store() {
5656 );
5757 assert!(wrong.load().await.is_err());
5858
59- let sessions = sessions();
60- let Some(Restore::Resumed(session)) = Session::restore(&sessions).await.unwrap() else {
59+ let sessions = Arc::new(sessions());
60+ let Some(Restore::Resumed(session)) = Session::restore(sessions.clone()).await.unwrap() else {
6161 panic!("the saved login didn't resume");
6262 };
6363 let (engine, handle, mut events) = session.build(config).await.unwrap();
@@ -72,7 +72,7 @@ async fn password_session_round_trips_and_discards_with_its_store() {
7272 sessions.discard().await.unwrap();
7373 assert!(!record_path.exists());
7474 assert!(!store_path.exists());
75- assert!(Session::restore(&sessions).await.unwrap().is_none());
75+ assert!(Session::restore(sessions).await.unwrap().is_none());
7676 std::fs::remove_dir_all(root).unwrap();
7777 }
7878
@@ -90,18 +90,19 @@ async fn a_restart_after_a_soft_logout_still_knows_the_device() {
9090 };
9191 Session::login_password(
9292 PasswordLogin::new(server.uri(), "example", "wordpass"),
93- &sessions(),
93+ Arc::new(sessions()),
9494 )
9595 .await
9696 .unwrap();
9797 sessions().soft_log_out().await.unwrap();
9898
99- let Some(Restore::SoftLoggedOut(soft)) = Session::restore(&sessions()).await.unwrap() else {
99+ let Some(Restore::SoftLoggedOut(soft)) = Session::restore(Arc::new(sessions())).await.unwrap()
100+ else {
100101 panic!("a soft logged out record came back as something else");
101102 };
102103 // the device matrix-sdk-test's canned login response hands out
103104 assert_eq!(soft.device_id, "GHTYAJCE");
104- Session::login_password(soft.password_login("wordpass"), &sessions())
105+ Session::login_password(soft.password_login("wordpass"), Arc::new(sessions()))
105106 .await
106107 .unwrap();
107108 let requests = server.server().received_requests().await.unwrap();
@@ -113,7 +114,7 @@ async fn a_restart_after_a_soft_logout_still_knows_the_device() {
113114 .unwrap();
114115 assert_eq!(relogin["device_id"], "GHTYAJCE");
115116 assert!(matches!(
116- Session::restore(&sessions()).await.unwrap(),
117+ Session::restore(Arc::new(sessions())).await.unwrap(),
117118 Some(Restore::Resumed(_))
118119 ));
119120 std::fs::remove_dir_all(root).unwrap();
@@ -153,6 +154,73 @@ async fn a_torn_or_failed_save_leaves_the_old_record() {
153154 std::fs::remove_dir_all(root).unwrap();
154155 }
155156
157+#[tokio::test]
158+async fn a_refresh_is_saved_before_anyone_hears_of_it() {
159+ use matrix_sdk_test::test_json;
160+ use wiremock::{
161+ Mock, ResponseTemplate,
162+ matchers::{header, method, path, path_regex},
163+ };
164+
165+ let server = MatrixMockServer::new().await;
166+ server.mock_versions().ok().mount().await;
167+ server
168+ .mock_login()
169+ .respond_with(
170+ ResponseTemplate::new(200).set_body_json(&*test_json::LOGIN_WITH_REFRESH_TOKEN),
171+ )
172+ .mount()
173+ .await;
174+ Mock::given(method("POST"))
175+ .and(path_regex("sync"))
176+ .and(header("authorization", "Bearer abc123"))
177+ .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
178+ "errcode": "M_UNKNOWN_TOKEN",
179+ "error": "expired",
180+ "soft_logout": false,
181+ })))
182+ .with_priority(1)
183+ .mount(server.server())
184+ .await;
185+ Mock::given(method("POST"))
186+ .and(path("/_matrix/client/v3/refresh"))
187+ .respond_with(
188+ ResponseTemplate::new(200).set_body_json(&*test_json::REFRESH_TOKEN_WITH_REFRESH_TOKEN),
189+ )
190+ .mount(server.server())
191+ .await;
192+ server
193+ .mock_sliding_sync()
194+ .ok(v5::Response::new("1".to_owned()))
195+ .mount()
196+ .await;
197+ let root = temporary_path();
198+ let sessions = Arc::new(EncryptedSessionStore::file(
199+ root.join("session"),
200+ StoreConfig::sqlite(root.join("store"), "test store passphrase"),
201+ ));
202+ let session = Session::login_password(
203+ PasswordLogin::new(server.uri(), "example", "wordpass"),
204+ sessions.clone(),
205+ )
206+ .await
207+ .unwrap();
208+ let (engine, handle, mut events) = session
209+ .build(EngineConfig::default().with_initial_history_limit(0))
210+ .await
211+ .unwrap();
212+ let running = tokio::spawn(engine.run());
213+ support::receive_until(&mut events, |event| {
214+ matches!(event, Event::SessionTokensChanged)
215+ })
216+ .await;
217+ let saved = serde_json::to_value(sessions.load().await.unwrap().unwrap()).unwrap();
218+ assert_eq!(saved["authentication"]["session"]["refresh_token"], "wxyz");
219+ support::shut_down(&handle, &mut events, running).await;
220+ drop(handle);
221+ std::fs::remove_dir_all(root).unwrap();
222+}
223+
156224 fn record(homeserver: &str) -> SessionRecord {
157225 serde_json::from_value(serde_json::json!({
158226 "homeserver": homeserver,