atagen

macltok

public
Star0
Watch0

Commit fe4133dbcd47

tarball

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

13 files changed+106-50
engine: say why the engine stopped

Engine::run answered Ok(()) for a shutdown, a logout and a revoked token alike, so a host deciding whether to bin its record and store had to reconstruct the cause from events it might not have seen. A soft logout also needs the device to log back in as, which nothing handed over. Termination names the cause and carries that device id, and Termination::retain turns it into what to keep.
Modified file: README.md+7 additions−5 deletions
@@ -38,11 +38,13 @@ separately from its encrypted store. On `SessionTokensChanged`, obtain a new rec
3838 `ClientHandle::session_record()` and replace the saved record atomically in
3939 protected storage. Restore with the same store location and passphrase.
4040
41-`LoggedOut` and `SessionAccessRejected` both stop the engine. after a logout or a
42-hard rejection the record and the store are dead, bin them. a soft logout wants
43-the store kept and a fresh login with `PasswordLogin::device_id` set to the old
44-device, or the e2ee keys go with it. a bare `Stopped` means keep everything. one
45-store directory per login, the sdk won't share a crypto store between devices.
41+`Engine::run` resolves to a `Termination` saying why it stopped, and
42+`Termination::retain` says what to do about it. after `LoggedOut` or
43+`AccessRevoked` the record and the store are dead, bin them. `SoftLoggedOut`
44+wants the store kept and a fresh login with `PasswordLogin::device_id` set to the
45+device it carries, or the e2ee keys go with it. `ShutDown` and `HandlesDropped`
46+keep everything. an `Err` is a real failure and kills nothing. one store
47+directory per login, the sdk won't share a crypto store between devices.
4648 The headless example is
4749 login-only; it does not persist sessions across runs.
4850
Modified file: docs/design-decisions.md+3 additions−1 deletions
@@ -41,7 +41,9 @@ the send queues, since the sdk parks a room's queue on any send failure and
4141 never unparks it. a recoverable failure while online gets the room another go
4242 after a few seconds
4343
44-logout and a rejected token both stop the engine. `Shutdown` answers once
44+logout and a rejected token both stop the engine. `run` resolves to a
45+`Termination` naming which of those it was, or a shutdown, or every handle
46+dropping, so the host never works it out from the events. `Shutdown` answers once
4547 `Stopped` has gone out, every one of them if several were queued
4648
4749 `Engine::run` hands back a boxed future. the real one nests deep enough that
Modified file: examples/headless.rs+7 additions−6 deletions
@@ -1,6 +1,6 @@
11 use macltok::{
2- EngineConfig, Event, PasswordLogin, RoomMembership, Session, StoreConfig, TimelineContent,
3- TimelineId, TimelineItem,
2+ EngineConfig, Event, PasswordLogin, Retain, RoomMembership, Session, StoreConfig,
3+ TimelineContent, TimelineId, TimelineItem,
44 };
55
66 #[tokio::main]
@@ -77,9 +77,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
7777 Event::SessionTokensChanged => {
7878 std::fs::write(&record_path, serde_json::to_vec(&handle.session_record()?)?)?;
7979 }
80- Event::LoggedOut | Event::SessionAccessRejected { soft_logout: false } => {
81- std::fs::remove_dir_all(&store_path)?;
82- }
8380 Event::Stopped => break,
8481 _ => {}
8582 }
@@ -91,7 +88,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9188 }
9289 }
9390 }
94- running.await??;
91+ match running.await??.retain() {
92+ Retain::RecordAndStore => {}
93+ Retain::Store => std::fs::remove_file(&record_path)?,
94+ Retain::Nothing => std::fs::remove_dir_all(&store_path)?,
95+ }
9596 Ok(())
9697 }
9798
Modified file: src/engine/mod.rs+23 additions−21 deletions
@@ -18,7 +18,7 @@ use matrix_sdk::{Client, SendOutsideWasm, SessionChange};
1818 use matrix_sdk_ui::sync_service::SyncService;
1919 use ruma::OwnedRoomId;
2020 use thiserror::Error as ThisError;
21-use tokio::sync::{Notify, broadcast, mpsc, watch};
21+use tokio::sync::{broadcast, mpsc, watch};
2222
2323 pub(crate) use media::MediaService;
2424
@@ -32,7 +32,7 @@ use self::{
3232 use crate::{
3333 AccountCommand, ClientHandle, Command, ConnectionState, EncryptionCommand, EngineErrorKind,
3434 EngineFailure, Error, ErrorKind, Event, EventStream, LifecycleCommand, MediaCommand,
35- NotificationCommand, RoomCommand, TimelineCommand,
35+ NotificationCommand, RoomCommand, Termination, TimelineCommand,
3636 transport::{DriverPort, Request, Responder, transport_pair},
3737 };
3838
@@ -246,7 +246,8 @@ pub struct Engine {
246246 uploads: HashMap<OwnedRoomId, watch::Receiver<bool>>,
247247 /// the client can outlive us, so the handler has to come off it when we stop
248248 verification_handler: Option<matrix_sdk::event_handler::EventHandlerHandle>,
249- stop: Arc<Notify>,
249+ stop: mpsc::UnboundedSender<Termination>,
250+ stops: mpsc::UnboundedReceiver<Termination>,
250251 }
251252
252253 impl Engine {
@@ -286,6 +287,7 @@ impl Engine {
286287 );
287288 client.send_queue().enable_upload_progress(true);
288289 let (note_sender, notes) = mpsc::unbounded_channel();
290+ let (stop, stops) = mpsc::unbounded_channel();
289291 let (mutations, mutation_queue) = mpsc::channel(config.command_capacity);
290292 let (notifications, notification_queue) = mpsc::channel(config.command_capacity);
291293 let (encryption, encryption_queue) = mpsc::channel(config.command_capacity);
@@ -314,18 +316,19 @@ impl Engine {
314316 }),
315317 uploads: HashMap::new(),
316318 verification_handler: None,
317- stop: Arc::new(Notify::new()),
319+ stop,
320+ stops,
318321 };
319322 Ok((engine, handle, events))
320323 }
321324
322325 /// boxed because the engine's future is deep enough to blow the compiler's query depth
323326 /// in every crate that spawns it, which isn't something a client should have to fix
324- pub fn run(self) -> EngineBoxFuture<'static, Result<(), EngineError>> {
327+ pub fn run(self) -> EngineBoxFuture<'static, Result<Termination, EngineError>> {
325328 Box::pin(self.run_inner())
326329 }
327330
328- async fn run_inner(mut self) -> Result<(), EngineError> {
331+ async fn run_inner(mut self) -> Result<Termination, EngineError> {
329332 let events = self.port.event_sender();
330333 let user_id = self
331334 .client
@@ -348,37 +351,32 @@ impl Engine {
348351 self.resume_upload_forwarders().await;
349352 self.sync.start().await;
350353
351- let mut failure = None;
352354 let mut shutdown_reply = None;
353- loop {
355+ let ending = loop {
354356 tokio::select! {
355357 biased;
356- () = events.closed() => {
357- failure = Some(EngineError::EventReceiverClosed);
358- break;
359- }
360- () = self.stop.notified() => break,
358+ () = events.closed() => break Err(EngineError::EventReceiverClosed),
359+ Some(termination) = self.stops.recv() => break Ok(termination),
361360 Some((context, panic)) = self.tasks.join_next(), if !self.tasks.is_empty() => {
362361 let Some(message) = panic else { continue };
363362 if let TaskContext::Command(label) = context {
364363 tracing::error!(label, panic = %message, "command task panicked");
365364 continue;
366365 }
367- failure = Some(EngineError::TaskPanicked { task: context.label() });
368- break;
366+ break Err(EngineError::TaskPanicked { task: context.label() });
369367 }
370368 Some(note) = self.notes.recv() => self.actor_note(note).await,
371369 request = self.port.recv() => {
372- let Some(request) = request else { break };
370+ let Some(request) = request else { break Ok(Termination::HandlesDropped) };
373371 if let Some(reply) = self.dispatch(request).await {
374372 shutdown_reply = Some(reply);
375- break;
373+ break Ok(Termination::ShutDown);
376374 }
377375 }
378376 }
379- }
377+ };
380378
381- if let Some(error) = &failure {
379+ if let Err(error) = &ending {
382380 let _ = events.send(Event::Failed(error.failure())).await;
383381 }
384382 self.shutdown().await;
@@ -392,7 +390,7 @@ impl Engine {
392390 reply.done(Ok(()));
393391 }
394392 }
395- failure.map_or(Ok(()), Err)
393+ ending
396394 }
397395
398396 fn start_tasks(&mut self) {
@@ -422,6 +420,10 @@ impl Engine {
422420 Box::pin(watchers::session(
423421 queues.session_changes,
424422 events(),
423+ client
424+ .device_id()
425+ .expect("authentication was checked during construction")
426+ .to_owned(),
425427 self.stop.clone(),
426428 )),
427429 ),
@@ -523,7 +525,7 @@ impl Engine {
523525 .await
524526 .map_err(|source| Error::from_sdk("logout failed", source))?;
525527 let _ = events.send(Event::LoggedOut).await;
526- stop.notify_one();
528+ let _ = stop.send(Termination::LoggedOut);
527529 Ok(())
528530 });
529531 }
Modified file: src/engine/watchers.rs+10 additions−5 deletions
@@ -7,12 +7,12 @@ use matrix_sdk::{
77 send_queue::{LocalEchoContent, RoomSendQueueUpdate},
88 };
99 use matrix_sdk_ui::sync_service::{State as SyncState, SyncService};
10-use ruma::time::Instant;
11-use tokio::sync::{Notify, broadcast::error::RecvError, watch};
10+use ruma::{OwnedDeviceId, time::Instant};
11+use tokio::sync::{broadcast::error::RecvError, mpsc, watch};
1212
1313 use super::executor::TaskSet;
1414 use crate::{
15- ConnectionState, Event, encryption::encryption_state, error::classify_sdk_error,
15+ ConnectionState, Event, Termination, encryption::encryption_state, error::classify_sdk_error,
1616 transport::EventSender,
1717 };
1818
@@ -35,7 +35,8 @@ pub(super) async fn room_subscriptions(
3535 pub(super) async fn session(
3636 mut changes: tokio::sync::broadcast::Receiver<SessionChange>,
3737 events: EventSender,
38- stop: Arc<Notify>,
38+ device_id: OwnedDeviceId,
39+ stop: mpsc::UnboundedSender<Termination>,
3940 ) {
4041 loop {
4142 let event = match changes.recv().await {
@@ -46,7 +47,11 @@ pub(super) async fn session(
4647 soft_logout: data.soft_logout,
4748 })
4849 .await;
49- stop.notify_one();
50+ let _ = stop.send(if data.soft_logout {
51+ Termination::SoftLoggedOut { device_id }
52+ } else {
53+ Termination::AccessRevoked
54+ });
5055 return;
5156 }
5257 // a missed refresh costs one needless save, a missed rejection comes round again
Modified file: src/lib.rs+3 additions−3 deletions
@@ -33,10 +33,10 @@ pub use model::{
3333 MembershipChange, Message, MessageContent, MessageEditContent, MessageKind, MessageMentions,
3434 NotificationCommand, OwnDevice, PowerLevelChanges, Profile, PushNotification,
3535 PushNotificationEvent, PushNotificationRoom, PushNotificationSender, PushNotificationStatus,
36- Reaction, RecoveryState, ReplyDetails, ReplyReference, RichText, RoomCategory, RoomCommand,
37- RoomHero, RoomListFilter, RoomMatcher, RoomMembership, RoomNotificationMode,
36+ Reaction, RecoveryState, ReplyDetails, ReplyReference, Retain, RichText, RoomCategory,
37+ RoomCommand, RoomHero, RoomListFilter, RoomMatcher, RoomMembership, RoomNotificationMode,
3838 RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary, RoomVisibility, SendState,
39- Sender, SpaceChild, ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff,
39+ Sender, SpaceChild, Termination, ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff,
4040 TimelineDiffError, TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState,
4141 UploadDetails, UploadThumbnail, UtdCause, VerificationAction, VerificationFailure,
4242 VerificationState, VerificationTarget,
Modified file: src/model.rs+37 additions
@@ -682,6 +682,43 @@ pub enum Event {
682682 Stopped,
683683 }
684684
685+/// why `Engine::run` came back, when nothing went wrong
686+#[derive(Clone, Debug, Eq, PartialEq)]
687+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
688+#[non_exhaustive]
689+pub enum Termination {
690+ ShutDown,
691+ /// every `ClientHandle` was dropped, so nobody could have asked for anything else
692+ HandlesDropped,
693+ LoggedOut,
694+ /// the server rejected our token for good
695+ AccessRevoked,
696+ /// the server wants this device to log in again, and its e2ee keys live in the store
697+ SoftLoggedOut {
698+ device_id: OwnedDeviceId,
699+ },
700+}
701+
702+impl Termination {
703+ #[must_use]
704+ pub const fn retain(&self) -> Retain {
705+ match self {
706+ Self::ShutDown | Self::HandlesDropped => Retain::RecordAndStore,
707+ Self::SoftLoggedOut { .. } => Retain::Store,
708+ Self::LoggedOut | Self::AccessRevoked => Retain::Nothing,
709+ }
710+ }
711+}
712+
713+/// what's still worth keeping of the saved record and the store
714+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
715+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
716+pub enum Retain {
717+ RecordAndStore,
718+ Store,
719+ Nothing,
720+}
721+
685722 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
686723 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
687724 #[non_exhaustive]
Modified file: tests/commands.rs+1 additions−1 deletions
@@ -130,7 +130,7 @@ async fn start_engine(
130130 MatrixMockServer,
131131 ClientHandle,
132132 EventStream,
133- JoinHandle<Result<(), EngineError>>,
133+ JoinHandle<Result<macltok::Termination, EngineError>>,
134134 ) {
135135 let server = MatrixMockServer::new().await;
136136 server
Modified file: tests/media.rs+1 additions−1 deletions
@@ -35,7 +35,7 @@ struct MediaHarness {
3535 client: matrix_sdk::Client,
3636 handle: ClientHandle,
3737 events: EventStream,
38- running: tokio::task::JoinHandle<Result<(), EngineError>>,
38+ running: tokio::task::JoinHandle<Result<macltok::Termination, EngineError>>,
3939 media_id: MediaId,
4040 }
4141
Modified file: tests/send_queue.rs+1 additions−1 deletions
@@ -132,7 +132,7 @@ async fn setup() -> (
132132 Client,
133133 ClientHandle,
134134 EventStream,
135- tokio::task::JoinHandle<Result<(), macltok::EngineError>>,
135+ tokio::task::JoinHandle<Result<macltok::Termination, macltok::EngineError>>,
136136 ) {
137137 let server = MatrixMockServer::new().await;
138138 server
Modified file: tests/state.rs+6 additions−2 deletions
@@ -2,7 +2,9 @@ mod support;
22
33 use std::time::Duration;
44
5-use macltok::{ConnectionState, Engine, EngineConfig, EngineError, Event, RoomMembership};
5+use macltok::{
6+ ConnectionState, Engine, EngineConfig, EngineError, Event, Retain, RoomMembership, Termination,
7+};
68 use matrix_sdk::{
79 ruma::{api::client::sync::sync_events::v5, room_id},
810 test_utils::mocks::MatrixMockServer,
@@ -92,7 +94,9 @@ async fn soft_logout_says_so_and_stops_the_engine() {
9294 })
9395 .await;
9496 support::receive_until(&mut events, |event| matches!(event, Event::Stopped)).await;
95- running.await.unwrap().unwrap();
97+ let termination = running.await.unwrap().unwrap();
98+ assert!(matches!(termination, Termination::SoftLoggedOut { .. }));
99+ assert_eq!(termination.retain(), Retain::Store);
96100 }
97101
98102 #[tokio::test]
Modified file: tests/support/mod.rs+5 additions−2 deletions
@@ -24,13 +24,16 @@ pub async fn receive_until(events: &mut EventStream, predicate: impl Fn(&Event)
2424 pub async fn shut_down(
2525 handle: &ClientHandle,
2626 events: &mut EventStream,
27- running: tokio::task::JoinHandle<Result<(), macltok::EngineError>>,
27+ running: tokio::task::JoinHandle<Result<macltok::Termination, macltok::EngineError>>,
2828 ) {
2929 let (answer, _) = tokio::join!(handle.shutdown(), async {
3030 receive_until(events, |event| matches!(event, Event::Stopped)).await
3131 });
3232 answer.unwrap();
33- running.await.unwrap().unwrap();
33+ assert_eq!(
34+ running.await.unwrap().unwrap(),
35+ macltok::Termination::ShutDown
36+ );
3437 }
3538
3639 pub fn event_messages(event: &Event) -> Vec<&TimelineEvent> {
Modified file: tests/tier0.rs+2 additions−2 deletions
@@ -330,7 +330,7 @@ async fn start_engine() -> (
330330 MatrixMockServer,
331331 ClientHandle,
332332 EventStream,
333- JoinHandle<Result<(), EngineError>>,
333+ JoinHandle<Result<macltok::Termination, EngineError>>,
334334 ) {
335335 let (server, client) = configured_client().await;
336336 let (handle, events, running) = run_client(client).await;
@@ -342,7 +342,7 @@ async fn run_client(
342342 ) -> (
343343 ClientHandle,
344344 EventStream,
345- JoinHandle<Result<(), EngineError>>,
345+ JoinHandle<Result<macltok::Termination, EngineError>>,
346346 ) {
347347 let config = EngineConfig::default().with_initial_history_limit(0);
348348 let (engine, handle, events) = Engine::from_client(client, config).await.unwrap();