atagen

macltok

public
Star0
Watch0

Commit a250c7fa43fb

tarball

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

9 files changed+360-33
account: allow one engine per store, and keep the login when the first save fails

The store is held by a platform lock, a flock on macltok.lock natively and a Web Lock in a browser, because matrix-sdk 0.18's lease abandons its renewal silently after one failed write or a 500ms stall and would hand a live store to a second engine. A login whose first record save fails hands its session back as AccountError::Unsaved, since the server already has the device and logging in again would spend another.
Modified file: README.md+7 additions−2 deletions
@@ -126,8 +126,13 @@ what's different in a browser
126126 has no upload progress
127127 - the sdk doesn't retry requests or time them out individually. sync still
128128 restarts itself with backoff, that part is ours
129-- two tabs or workers on one store each need `StoreConfig::with_lock_holder`
130- with a name of their own, or they both think they hold the crypto store
129+- an OAuth login only finishes in the process that began it, since the sdk
130+ keeps its pending state in memory. run it in a popup, or in a worker that
131+ outlives the redirect, not by navigating the page away
132+- one engine per store. a second tab or worker opening a store that's in use
133+ gets `StoreInUse` straight away, so serve many tabs from one engine in a
134+ SharedWorker rather than one each. the store's Web Lock needs a secure
135+ context
131136 - `discard` while another tab still has the store open answers
132137 `StoreDeletePending`. the delete lands once that tab lets go, and reopening
133138 the store waits behind it
Modified file: docs/design-decisions.md+37 additions−1 deletions
@@ -222,7 +222,43 @@ would restore into empty crypto. the host follows `Termination::retain`.
222222 swaps the record for one holding the homeserver, user, device and oauth client
223223 id with no tokens. `restore` hands that back as `Restore::SoftLoggedOut`, so a
224224 restart still knows which device to log in again as, and the login overwrites
225-it. one store directory per login
225+it. one store directory per login. a login whose first save fails comes back as
226+`AccountError::Unsaved` holding the live session, so the host retries the save
227+rather than spending another device
228+
229+one engine per store. before the client opens the store we take the platform's
230+own exclusive lock on it, `File::try_lock` on `macltok.lock` in the store
231+directory natively and a Web Lock named after the store in a browser. the
232+engine and every `ClientHandle` share it, so it goes when the last of them
233+does, since a lingering handle still has the client and its store open. anyone
234+else, in this process, another one or another tab, gets `StoreInUse` at once,
235+so the host decides whether to wait, hand over or say so. the os or the browser
236+lets go when the holder dies. a SharedWorker serving every tab from one engine
237+never contends for it. Web Locks only exist in a secure context, so a browser
238+store needs one too
239+
240+the sdk's cross process lease can't do this job. in matrix-sdk-common 0.18
241+`CrossProcessLock::try_lock_once` (`cross_process_lock.rs` 389-448) leases for
242+500ms and renews every 50ms from a spawned task, which gives up for good on one
243+failed renewal or on finding the lease lapsed and retaken, and tells no one. a
244+stall past half a second, ordinary in a tab and certain during the record's
245+PBKDF2, or one `SQLITE_BUSY`, hands the store to a second engine while the
246+first still thinks it's alone. with our lock keeping everyone else out, clients
247+run with `CrossProcessLockConfig::SingleProcess` and the sdk's own sections skip
248+the lease writes and renewal tasks
249+
250+a pending OAuth login isn't persisted. `begin_oauth` and `finish` have to run in
251+the same process, so a web host runs the login in a popup or in a worker that
252+outlives the redirect. the sdk keeps what `finish_login` needs, the PKCE
253+verifier among it, in `AuthorizationValidationData` inside a `pub(crate)` map
254+keyed by `CsrfToken` (matrix-sdk 0.18, `authentication/oauth/mod.rs` around
255+262-266 and 1744-1756). nothing public reads it or puts it back, and
256+`finish_login` on a fresh client answers `InvalidState`. doing the code grant
257+ourselves would copy some 250 lines of the sdk's private oauth behaviour and
258+drift from it every upgrade. patching the sdk to export and import that data is
259+small, but it means carrying a matrix-sdk fork, which we won't for now. upstream
260+exposing export and import of pending authorization data, or a fork becoming
261+acceptable, reopens this
226262
227263 ## room lists, spaces and profiles
228264
Modified file: src/account.rs+98 additions−25 deletions
@@ -26,7 +26,8 @@ use url::Url;
2626
2727 use crate::{
2828 ClientHandle, Engine, EngineConfig, EngineError, ErrorKind, EventStream, SessionStore,
29- engine::Persistence, error::SourceError,
29+ engine::{Persistence, StoreLock},
30+ error::SourceError,
3031 };
3132
3233 /// a logged in client and the `SessionStore` its record lives in. the engine it builds
@@ -34,6 +35,7 @@ use crate::{
3435 pub struct Session {
3536 client: Client,
3637 tracking: Tracking,
38+ store_lock: StoreLock,
3739 }
3840
3941 /// taken as soon as the client exists, since the sdk can refresh before there's an engine
@@ -143,7 +145,7 @@ pub enum OAuthApplicationType {
143145 pub struct PendingOAuthLogin {
144146 client: Client,
145147 sessions: Arc<dyn SessionStore>,
146- tracking: Option<Tracking>,
148+ tracking: Option<(Tracking, StoreLock)>,
147149 authorization_url: String,
148150 state: Option<CsrfToken>,
149151 }
@@ -181,11 +183,17 @@ impl PendingOAuthLogin {
181183 oauth.abort_login(&state).await;
182184 return Err(account_error("OAuth login", error));
183185 }
184- let tracking = self
186+ let (tracking, store_lock) = self
185187 .tracking
186188 .take()
187189 .ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;
188- Session::saved(self.client.clone(), &*self.sessions, tracking).await
190+ Session::saved(
191+ self.client.clone(),
192+ self.sessions.clone(),
193+ tracking,
194+ store_lock,
195+ )
196+ .await
189197 }
190198
191199 pub async fn abort(mut self) {
@@ -198,7 +206,6 @@ impl PendingOAuthLogin {
198206 pub struct StoreConfig {
199207 location: StoreLocation,
200208 passphrase: String,
201- lock_holder: Option<String>,
202209 }
203210
204211 enum StoreLocation {
@@ -215,7 +222,6 @@ impl StoreConfig {
215222 Self {
216223 location: StoreLocation::Sqlite(path.into()),
217224 passphrase: passphrase.into(),
218- lock_holder: None,
219225 }
220226 }
221227
@@ -225,17 +231,9 @@ impl StoreConfig {
225231 Self {
226232 location: StoreLocation::IndexedDb(name.into()),
227233 passphrase: passphrase.into(),
228- lock_holder: None,
229234 }
230235 }
231236
232- /// the sdk names every client `main`, so two tabs on one store would both think they hold its lock
233- #[must_use]
234- pub fn with_lock_holder(mut self, holder: impl Into<String>) -> Self {
235- self.lock_holder = Some(holder.into());
236- self
237- }
238-
239237 /// delete the store's databases, once nothing has them open
240238 pub async fn delete(&self) -> Result<(), AccountError> {
241239 match &self.location {
@@ -249,6 +247,17 @@ impl StoreConfig {
249247 }
250248 }
251249
250+ /// taken before the client opens the store, and held until the engine and every
251+ /// `ClientHandle` are gone
252+ pub(crate) async fn lock(&self) -> Result<StoreLock, AccountError> {
253+ match &self.location {
254+ #[cfg(feature = "sqlite")]
255+ StoreLocation::Sqlite(path) => StoreLock::file(path),
256+ #[cfg(feature = "indexeddb")]
257+ StoreLocation::IndexedDb(name) => StoreLock::web(name).await,
258+ }
259+ }
260+
252261 pub(crate) fn passphrase(&self) -> &str {
253262 &self.passphrase
254263 }
@@ -352,15 +361,62 @@ pub enum AccountError {
352361 /// once they close, and opening the store again waits behind it
353362 #[error("the store is still open elsewhere, it will be deleted once that closes")]
354363 StoreDeletePending,
364+ /// another engine, in this process or another, has the store. only one runs per store
365+ #[error("another engine is using this store")]
366+ StoreInUse,
367+ /// the login worked but its first record didn't save. the server already has the
368+ /// device, so retry the save rather than log in again
369+ #[error("saving the new login's record failed")]
370+ Unsaved {
371+ session: Box<UnsavedSession>,
372+ #[source]
373+ source: Box<AccountError>,
374+ },
355375 #[error(transparent)]
356376 Engine(#[from] EngineError),
357377 }
358378
379+/// a live login whose record never reached its `SessionStore`
380+pub struct UnsavedSession {
381+ session: Session,
382+ sessions: Arc<dyn SessionStore>,
383+}
384+
385+impl UnsavedSession {
386+ pub async fn save(self) -> Result<Session, AccountError> {
387+ let saved = match session_record(&self.session.client) {
388+ Ok(record) => self.sessions.save(&record).await,
389+ Err(error) => Err(error),
390+ };
391+ match saved {
392+ Ok(()) => Ok(self.session),
393+ Err(source) => Err(AccountError::Unsaved {
394+ session: Box::new(self),
395+ source: Box::new(source),
396+ }),
397+ }
398+ }
399+
400+ /// the record to keep somewhere else, if the store won't take it
401+ pub fn record(&self) -> Result<SessionRecord, AccountError> {
402+ session_record(&self.session.client)
403+ }
404+}
405+
406+impl std::fmt::Debug for UnsavedSession {
407+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408+ formatter
409+ .debug_struct("UnsavedSession")
410+ .finish_non_exhaustive()
411+ }
412+}
413+
359414 impl Session {
360415 pub async fn login_password(
361416 login: PasswordLogin,
362417 sessions: Arc<dyn SessionStore>,
363418 ) -> Result<Self, AccountError> {
419+ let store_lock = sessions.store().lock().await?;
364420 let client = matrix_client_builder(sessions.store())
365421 .server_name_or_homeserver_url(login.homeserver.trim())
366422 .build()
@@ -381,9 +437,10 @@ impl Session {
381437 .send()
382438 .await
383439 .map_err(|error| account_error("password login", error))?;
384- Self::saved(client, &*sessions, tracking).await
440+ Self::saved(client, sessions, tracking, store_lock).await
385441 }
386442
443+ /// the pending login lives only in this process, so the one that begins it has to finish it
387444 pub async fn begin_oauth(
388445 login: OAuthLogin,
389446 sessions: Arc<dyn SessionStore>,
@@ -392,6 +449,7 @@ impl Session {
392449 .map_err(|error| account_error("parsing the OAuth redirect URI", error))?;
393450 let client_uri = Url::parse(login.client_uri.trim())
394451 .map_err(|error| account_error("parsing the OAuth client URI", error))?;
452+ let store_lock = sessions.store().lock().await?;
395453 let client = matrix_client_builder(sessions.store())
396454 .server_name_or_homeserver_url(login.homeserver.trim())
397455 .build()
@@ -428,7 +486,7 @@ impl Session {
428486 Ok(PendingOAuthLogin {
429487 client,
430488 sessions,
431- tracking: Some(tracking),
489+ tracking: Some((tracking, store_lock)),
432490 authorization_url: authorization.url.to_string(),
433491 state: Some(authorization.state),
434492 })
@@ -454,6 +512,7 @@ impl Session {
454512 })));
455513 }
456514 };
515+ let store_lock = sessions.store().lock().await?;
457516 let client = matrix_client_builder(sessions.store())
458517 .homeserver_url(&record.homeserver)
459518 .build()
@@ -464,7 +523,11 @@ impl Session {
464523 .restore_session(authentication)
465524 .await
466525 .map_err(|error| account_error("restoring the authenticated session", error))?;
467- Ok(Some(Restore::Resumed(Self { client, tracking })))
526+ Ok(Some(Restore::Resumed(Self {
527+ client,
528+ tracking,
529+ store_lock,
530+ })))
468531 }
469532
470533 /// leave the record to the host, which then saves `ClientHandle::session_record()`
@@ -486,6 +549,7 @@ impl Session {
486549 config,
487550 self.tracking.session_changes,
488551 self.tracking.persistence,
552+ Some(self.store_lock),
489553 )
490554 .await
491555 .map_err(AccountError::from)
@@ -493,11 +557,20 @@ impl Session {
493557
494558 async fn saved(
495559 client: Client,
496- sessions: &dyn SessionStore,
560+ sessions: Arc<dyn SessionStore>,
497561 tracking: Tracking,
562+ store_lock: StoreLock,
498563 ) -> Result<Self, AccountError> {
499- sessions.save(&session_record(&client)?).await?;
500- Ok(Self { client, tracking })
564+ UnsavedSession {
565+ session: Self {
566+ client,
567+ tracking,
568+ store_lock,
569+ },
570+ sessions,
571+ }
572+ .save()
573+ .await
501574 }
502575 }
503576
@@ -587,16 +660,16 @@ fn encryption_settings() -> EncryptionSettings {
587660 }
588661 }
589662
663+/// `StoreLock` already keeps every other client off the store, and the sdk's lease would cost
664+/// a store write per locked section and a task renewing it every 50ms
590665 fn matrix_client_builder(store: &StoreConfig) -> ClientBuilder {
591- let mut builder = Client::builder()
666+ let builder = Client::builder()
592667 .with_encryption_settings(encryption_settings())
593668 .with_threading_support(ThreadingSupport::Enabled {
594669 with_subscriptions: false,
595670 })
596- .handle_refresh_tokens();
597- if let Some(holder) = &store.lock_holder {
598- builder = builder.cross_process_store_config(CrossProcessLockConfig::multi_process(holder));
599- }
671+ .handle_refresh_tokens()
672+ .cross_process_store_config(CrossProcessLockConfig::SingleProcess);
600673 match &store.location {
601674 #[cfg(feature = "sqlite")]
602675 StoreLocation::Sqlite(path) => builder.sqlite_store(path, Some(&store.passphrase)),
Modified file: src/engine/mod.rs+12 additions−1 deletions
@@ -25,6 +25,11 @@ use tokio::sync::{broadcast, mpsc, watch};
2525 pub(crate) use media::MediaService;
2626 pub(crate) use session::Persistence;
2727
28+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
29+pub(crate) use crate::store_lock::StoreLock;
30+#[cfg(not(any(feature = "sqlite", feature = "indexeddb")))]
31+pub(crate) enum StoreLock {}
32+
2833 use self::{
2934 commands::Job,
3035 executor::{EngineBoxFuture, TaskSet},
@@ -273,6 +278,8 @@ pub struct Engine {
273278 stops: mpsc::UnboundedReceiver<Termination>,
274279 logouts: Arc<watch::Sender<Logouts>>,
275280 preparations: prepare::Preparations,
281+ /// shared with every `ClientHandle`, so the store stays locked while the client is reachable
282+ _store_lock: Option<Arc<StoreLock>>,
276283 }
277284
278285 impl Engine {
@@ -282,7 +289,7 @@ impl Engine {
282289 config: EngineConfig,
283290 ) -> Result<(Self, ClientHandle, EventStream), EngineError> {
284291 let session_changes = client.subscribe_to_session_changes();
285- Self::with_persistence(client, config, session_changes, None).await
292+ Self::with_persistence(client, config, session_changes, None, None).await
286293 }
287294
288295 /// `session_changes` is taken when the client is built, since the sdk can refresh
@@ -292,6 +299,7 @@ impl Engine {
292299 config: EngineConfig,
293300 session_changes: broadcast::Receiver<SessionChange>,
294301 persistence: Option<Persistence>,
302+ store_lock: Option<StoreLock>,
295303 ) -> Result<(Self, ClientHandle, EventStream), EngineError> {
296304 if client.user_id().is_none() {
297305 return Err(EngineError::Unauthenticated);
@@ -309,6 +317,7 @@ impl Engine {
309317 .await
310318 .map_err(EngineError::SyncService)?,
311319 );
320+ let store_lock = store_lock.map(Arc::new);
312321 let media = Arc::new(MediaService::new(
313322 client.clone(),
314323 config.max_media_bytes,
@@ -318,6 +327,7 @@ impl Engine {
318327 client.clone(),
319328 sync.clone(),
320329 media.clone(),
330+ store_lock.clone(),
321331 config.command_capacity,
322332 config.event_capacity,
323333 );
@@ -360,6 +370,7 @@ impl Engine {
360370 stop,
361371 stops,
362372 logouts: Arc::new(watch::Sender::new(Logouts::default())),
373+ _store_lock: store_lock,
363374 };
364375 Ok((engine, handle, events))
365376 }
Modified file: src/lib.rs+3 additions−1 deletions
@@ -22,11 +22,13 @@ compile_error!("indexeddb only exists in a browser, use sqlite");
2222 mod account;
2323 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
2424 mod session_store;
25+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]
26+mod store_lock;
2527
2628 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
2729 pub use account::{
2830 AccountError, OAuthApplicationType, OAuthLogin, PasswordLogin, PendingOAuthLogin, Restore,
29- Session, SessionRecord, SoftLogout, StoreConfig,
31+ Session, SessionRecord, SoftLogout, StoreConfig, UnsavedSession,
3032 };
3133 #[cfg(any(feature = "sqlite", feature = "indexeddb"))]
3234 pub use session_store::{EncryptedSessionStore, SessionStore};
Modified file: src/session_store.rs+4 additions
@@ -487,6 +487,10 @@ pub(crate) fn delete_sqlite_store(path: &Path) -> std::io::Result<()> {
487487 }
488488 }
489489 }
490+ match fs::remove_file(path.join(crate::store_lock::LOCK_FILE)) {
491+ Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),
492+ _ => {}
493+ }
490494 match fs::remove_dir(path) {
491495 Err(error) if error.kind() != ErrorKind::NotFound => Err(error),
492496 _ => Ok(()),
Added file: src/store_lock.rs+117 additions
@@ -0,0 +1,117 @@
1+use crate::{AccountError, account::account_error};
2+
3+/// the platform's own exclusive lock on a store. matrix-sdk 0.18's cross process lease drops
4+/// itself silently after one failed renewal or a stall past 500ms, so it can't be what keeps a
5+/// second engine out
6+pub(crate) struct StoreLock {
7+ #[cfg(feature = "sqlite")]
8+ _file: std::fs::File,
9+ #[cfg(feature = "indexeddb")]
10+ release: js_sys::Function,
11+}
12+
13+#[cfg(feature = "sqlite")]
14+pub(crate) const LOCK_FILE: &str = "macltok.lock";
15+
16+#[cfg(feature = "sqlite")]
17+impl StoreLock {
18+ pub(crate) fn file(directory: &std::path::Path) -> Result<Self, AccountError> {
19+ use std::fs::{self, File, TryLockError};
20+
21+ fs::create_dir_all(directory)
22+ .map_err(|error| account_error("creating the Matrix store directory", error))?;
23+ let file = File::options()
24+ .create(true)
25+ .truncate(false)
26+ .write(true)
27+ .open(directory.join(LOCK_FILE))
28+ .map_err(|error| account_error("opening the Matrix store's lock file", error))?;
29+ match file.try_lock() {
30+ Ok(()) => Ok(Self { _file: file }),
31+ Err(TryLockError::WouldBlock) => Err(AccountError::StoreInUse),
32+ Err(TryLockError::Error(error)) => {
33+ Err(account_error("locking the Matrix store", error))
34+ }
35+ }
36+ }
37+}
38+
39+#[cfg(feature = "indexeddb")]
40+#[derive(Debug, thiserror::Error)]
41+enum WebLockError {
42+ #[error("this global scope has no Web Locks, they need a secure context")]
43+ Unavailable,
44+ #[error("requesting the Web Lock failed, {0}")]
45+ Failed(String),
46+}
47+
48+#[cfg(feature = "indexeddb")]
49+impl StoreLock {
50+ /// `ifAvailable` answers at once. the lock is ours until the promise we hand back settles
51+ pub(crate) async fn web(name: &str) -> Result<Self, AccountError> {
52+ use futures_util::future::{Either, select};
53+ use js_sys::{Function, Object, Promise, Reflect};
54+ use tokio::sync::oneshot;
55+ use wasm_bindgen::{JsCast, JsValue, closure::Closure};
56+
57+ let failed = |error: JsValue| {
58+ account_error(
59+ "locking the Matrix store",
60+ WebLockError::Failed(format!("{error:?}")),
61+ )
62+ };
63+ let locks = Reflect::get(&js_sys::global(), &JsValue::from_str("navigator"))
64+ .and_then(|navigator| Reflect::get(&navigator, &JsValue::from_str("locks")))
65+ .map_err(failed)?;
66+ if locks.is_undefined() {
67+ return Err(account_error(
68+ "locking the Matrix store",
69+ WebLockError::Unavailable,
70+ ));
71+ }
72+ let request: Function = Reflect::get(&locks, &JsValue::from_str("request"))
73+ .map_err(failed)?
74+ .dyn_into()
75+ .map_err(failed)?;
76+ let options = Object::new();
77+ Reflect::set(&options, &JsValue::from_str("ifAvailable"), &JsValue::TRUE)
78+ .map_err(failed)?;
79+
80+ let (granted, grant) = oneshot::channel();
81+ let callback = Closure::once(move |lock: JsValue| -> JsValue {
82+ if lock.is_null() {
83+ let _ = granted.send(None);
84+ return JsValue::UNDEFINED;
85+ }
86+ let mut release = None;
87+ let held = Promise::new(&mut |resolve, _| release = Some(resolve));
88+ let _ = granted.send(release);
89+ held.into()
90+ });
91+ let outcome: Promise = request
92+ .call3(
93+ &locks,
94+ &JsValue::from_str(&format!("macltok:{name}")),
95+ &options,
96+ callback.as_ref(),
97+ )
98+ .map_err(failed)?
99+ .dyn_into()
100+ .map_err(failed)?;
101+ match select(grant, wasm_bindgen_futures::JsFuture::from(outcome)).await {
102+ Either::Left((Ok(Some(release)), _)) => Ok(Self { release }),
103+ Either::Left((Ok(None), _)) => Err(AccountError::StoreInUse),
104+ Either::Left((Err(_), _)) | Either::Right((Ok(_), _)) => {
105+ Err(failed(JsValue::from_str("the lock callback never ran")))
106+ }
107+ Either::Right((Err(error), _)) => Err(failed(error)),
108+ }
109+ }
110+}
111+
112+#[cfg(feature = "indexeddb")]
113+impl Drop for StoreLock {
114+ fn drop(&mut self) {
115+ let _ = self.release.call0(&wasm_bindgen::JsValue::UNDEFINED);
116+ }
117+}
Modified file: src/transport.rs+4 additions−1 deletions
@@ -35,7 +35,7 @@ use crate::{
3535 RoomNotificationSettings, RoomPowerLevels, RoomSummary, SpaceChild, TimelineCommand,
3636 TimelineId, VerificationAction, VerificationTarget,
3737 encryption::encryption_state,
38- engine::MediaService,
38+ engine::{MediaService, StoreLock},
3939 model::{PushNotificationRoom, PushNotificationSender},
4040 normalise::{member_summary, room_summaries, space_child},
4141 };
@@ -49,6 +49,7 @@ pub struct ClientHandle {
4949 sync_service: Arc<SyncService>,
5050 media: Arc<MediaService>,
5151 spaces: Arc<OnceCell<SpaceService>>,
52+ _store_lock: Option<Arc<StoreLock>>,
5253 }
5354
5455 impl fmt::Debug for ClientHandle {
@@ -1006,6 +1007,7 @@ pub(crate) fn transport_pair(
10061007 client: Client,
10071008 sync_service: Arc<SyncService>,
10081009 media: Arc<MediaService>,
1010+ store_lock: Option<Arc<StoreLock>>,
10091011 command_capacity: usize,
10101012 event_capacity: usize,
10111013 ) -> (ClientHandle, DriverPort, EventStream) {
@@ -1021,6 +1023,7 @@ pub(crate) fn transport_pair(
10211023 sync_service,
10221024 media,
10231025 spaces: Arc::new(OnceCell::new()),
1026+ _store_lock: store_lock,
10241027 },
10251028 DriverPort {
10261029 requests: request_rx,
Modified file: tests/account.rs+78 additions−2 deletions
@@ -4,8 +4,8 @@ mod support;
44 use std::{path::PathBuf, sync::Arc, time::SystemTime};
55
66 use macltok::{
7- EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore, Session, SessionRecord,
8- SessionStore, StoreConfig,
7+ AccountError, EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore, Session,
8+ SessionRecord, SessionStore, StoreConfig,
99 };
1010 use matrix_sdk::{ruma::api::client::sync::sync_events::v5, test_utils::mocks::MatrixMockServer};
1111
@@ -121,6 +121,82 @@ async fn a_restart_after_a_soft_logout_still_knows_the_device() {
121121 }
122122
123123 #[tokio::test]
124+async fn a_login_whose_first_save_fails_can_still_be_saved() {
125+ let server = MatrixMockServer::new().await;
126+ server.mock_versions().ok().mount().await;
127+ server.mock_login().ok().mount().await;
128+ let root = temporary_path();
129+ let records = root.join("records");
130+ std::fs::create_dir_all(&records).unwrap();
131+ let sessions = || {
132+ EncryptedSessionStore::file(
133+ records.join("session"),
134+ StoreConfig::sqlite(root.join("store"), "test store passphrase"),
135+ )
136+ };
137+ let writable = std::fs::metadata(&records).unwrap().permissions();
138+ let mut read_only = writable.clone();
139+ read_only.set_readonly(true);
140+ std::fs::set_permissions(&records, read_only).unwrap();
141+
142+ let login = Session::login_password(
143+ PasswordLogin::new(server.uri(), "example", "wordpass"),
144+ Arc::new(sessions()),
145+ )
146+ .await;
147+ let Err(AccountError::Unsaved { session, .. }) = login else {
148+ panic!("a login that couldn't save its record didn't hand the session back");
149+ };
150+ std::fs::set_permissions(&records, writable).unwrap();
151+ drop(session.save().await.unwrap());
152+ assert!(matches!(
153+ Session::restore(Arc::new(sessions())).await.unwrap(),
154+ Some(Restore::Resumed(_))
155+ ));
156+ std::fs::remove_dir_all(root).unwrap();
157+}
158+
159+#[tokio::test]
160+async fn a_store_takes_one_session_at_a_time() {
161+ let server = MatrixMockServer::new().await;
162+ server.mock_versions().ok().mount().await;
163+ server.mock_login().ok().mount().await;
164+ let root = temporary_path();
165+ let sessions = || {
166+ EncryptedSessionStore::file(
167+ root.join("session"),
168+ StoreConfig::sqlite(root.join("store"), "test store passphrase"),
169+ )
170+ };
171+ let (engine, handle, events) = Session::login_password(
172+ PasswordLogin::new(server.uri(), "example", "wordpass"),
173+ Arc::new(sessions()),
174+ )
175+ .await
176+ .unwrap()
177+ .build(EngineConfig::default())
178+ .await
179+ .unwrap();
180+
181+ assert!(matches!(
182+ Session::restore(Arc::new(sessions())).await,
183+ Err(AccountError::StoreInUse)
184+ ));
185+ drop((engine, events));
186+ assert!(matches!(
187+ Session::restore(Arc::new(sessions())).await,
188+ Err(AccountError::StoreInUse)
189+ ));
190+ drop(handle);
191+ let resumed = Session::restore(Arc::new(sessions())).await.unwrap();
192+ assert!(matches!(resumed, Some(Restore::Resumed(_))));
193+ drop(resumed);
194+ sessions().discard().await.unwrap();
195+ assert!(!root.join("store").exists());
196+ std::fs::remove_dir_all(root).unwrap();
197+}
198+
199+#[tokio::test]
124200 async fn a_torn_or_failed_save_leaves_the_old_record() {
125201 let root = temporary_path();
126202 let record_path = root.join("session");