atagen

macltok

public
Star0
Watch0

Commit 38c4caecdb3d

tarball

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

7 files changed+198-1
engine: warm a room up for sending

The sdk does its member sync, key query, key claim and room key share inline on the send path, so under federation lag a send into an encrypted room can stall for a minute. prepare_to_send syncs an encrypted room's members ahead of time, which has the sdk mark them all tracked and run the /keys/query in its own background requests, deduplicated per room and run by itself on open and on typing unless EngineConfig::prepare_to_send is off. The claim and share stay on the send path until matrix-sdk exposes preshare_room_key.
Modified file: README.md+6 additions
@@ -36,6 +36,12 @@ and awaits one of those answers deadlocks once it fills. from the draining
3636 task, `try_send` the command and drop the `Pending`, the events tell you how it
3737 went
3838
39+`prepare_to_send` does the key work a send into an encrypted room would start
40+with, ahead of time, so the send itself is quicker. the engine runs it itself
41+when a room opens or `set_typing(room, true)` comes in, unless
42+`EngineConfig::with_prepare_to_send(false)`. only part of that work is reachable
43+without a matrix-sdk fork, `docs/design-decisions.md` says which
44+
3945 start a timeline from `TimelineReset`, then apply each `TimelineChanged` whole
4046 with `TimelineDiff::try_apply_all`. snapshots are `imbl::Vector`s, re-exported
4147 as `macltok::imbl`, so a copy costs nothing. when a batch doesn't fit none of it
Modified file: docs/design-decisions.md+30 additions
@@ -113,6 +113,36 @@ edit, redact and react take an `EventRef`, so your own message can be edited or
113113 reacted to before the server has echoed it back. redacting an unsent one just
114114 cancels it
115115
116+## warming up to send
117+
118+the sdk does all its key work on the send path, in the room's send queue before
119+the `/send` itself. members synced, a `/keys/query` for anyone untracked or
120+dirty, then a `/keys/claim` and the room key sent to every device. over a slow
121+federation that's a minute of nothing. `prepare_to_send` does what the public
122+api lets us of that, ahead of time, which comes down to syncing the members if
123+they aren't. the sdk dedups that with its own sync, and it marks every member
124+tracked and dirty, so the sdk's background outgoing requests run the
125+`/keys/query` long before the send would have. that's what moves the query off
126+the send path. we don't ask `request_user_identity` for anyone, since that's
127+an out of band query that never marks a user tracked or up to date
128+(matrix-sdk-crypto `identities/manager.rs` ~804-817), federated traffic the
129+send path would redo anyway. a synced room costs nothing, so there's no
130+cooldown. one warm-up per room at a time, a second ask joins it. opening a room
131+or starting to type runs one too, unless `EngineConfig::prepare_to_send` is
132+off. it's on because malice asked, not because anyone decided
133+
134+that's the leftovers of turning down a matrix-sdk fork. the `/keys/claim` and
135+the room key share, the slow federated half, stay on the send path. the
136+background `/keys/query` runs when the sdk next sends its outgoing requests,
137+after a sync response, and `send_outgoing_requests` is crate private, so a send
138+straight after a warm-up can still find the members dirty and query them itself.
139+and we can't join the dedup the sdk wraps its own prep in
140+
141+the hook upstream is the todo above `preshare_room_key` at matrix-sdk
142+`room/mod.rs` ~2392, asking for exactly this. it's still private on main and in
143+0.19.1 as of 2026-09-26. once upstream makes it public, or a fork becomes
144+acceptable, `prepare_to_send` should just call it and most of this goes
145+
116146 ## mutations
117147
118148 membership, moderation, power levels, room creation and account data writes go
Modified file: src/engine/mod.rs+33 additions−1 deletions
@@ -2,6 +2,7 @@ mod commands;
22 pub(crate) mod executor;
33 mod media;
44 mod mutations;
5+mod prepare;
56 mod room;
67 mod session;
78 mod timeline;
@@ -46,6 +47,8 @@ pub struct EngineConfig {
4647 pub initial_history_limit: u16,
4748 pub max_media_bytes: usize,
4849 pub max_media_sources: usize,
50+ /// warm a room up for sending when it's opened or someone starts typing in it
51+ pub prepare_to_send: bool,
4952 }
5053
5154 impl EngineConfig {
@@ -78,6 +81,12 @@ impl EngineConfig {
7881 self.max_media_sources = sources;
7982 self
8083 }
84+
85+ #[must_use]
86+ pub const fn with_prepare_to_send(mut self, prepare: bool) -> Self {
87+ self.prepare_to_send = prepare;
88+ self
89+ }
8190 }
8291
8392 impl Default for EngineConfig {
@@ -88,6 +97,7 @@ impl Default for EngineConfig {
8897 initial_history_limit: 30,
8998 max_media_bytes: 20 * 1024 * 1024,
9099 max_media_sources: 2_048,
100+ prepare_to_send: true,
91101 }
92102 }
93103 }
@@ -262,6 +272,7 @@ pub struct Engine {
262272 stop: mpsc::UnboundedSender<Termination>,
263273 stops: mpsc::UnboundedReceiver<Termination>,
264274 logouts: Arc<watch::Sender<Logouts>>,
275+ preparations: prepare::Preparations,
265276 }
266277
267278 impl Engine {
@@ -345,6 +356,7 @@ impl Engine {
345356 upload_reports,
346357 uploads: HashMap::new(),
347358 verification_handler: None,
359+ preparations: prepare::Preparations::default(),
348360 stop,
349361 stops,
350362 logouts: Arc::new(watch::Sender::new(Logouts::default())),
@@ -640,9 +652,21 @@ impl Engine {
640652 commands::delete_devices(&client, &device_ids, auth).await
641653 });
642654 }
643- Command::Room(RoomCommand::Open(room_id)) => self.open_room(room_id, reply).await,
655+ Command::Room(RoomCommand::Open(room_id)) => {
656+ if self.config.prepare_to_send {
657+ self.prepare_quietly(room_id.clone());
658+ }
659+ self.open_room(room_id, reply).await;
660+ }
644661 Command::Room(RoomCommand::Close(room_id)) => self.close_room(room_id, reply).await,
662+ Command::Room(RoomCommand::PrepareToSend(room_id)) => {
663+ let flight = self.preparations.join(&client, room_id);
664+ self.spawn_command("prepare to send", reply, flight);
665+ }
645666 Command::Room(RoomCommand::SetTyping { room_id, typing }) => {
667+ if typing && self.config.prepare_to_send {
668+ self.prepare_quietly(room_id.clone());
669+ }
646670 self.spawn_command("typing", reply, async move {
647671 commands::set_typing(&client, &room_id, typing).await
648672 });
@@ -687,6 +711,14 @@ impl Engine {
687711 });
688712 }
689713
714+ fn prepare_quietly(&mut self, room_id: OwnedRoomId) {
715+ let flight = self.preparations.join(&self.client, room_id);
716+ self.tasks
717+ .spawn(TaskContext::Command("prepare to send"), async move {
718+ let _ = flight.await;
719+ });
720+ }
721+
690722 fn upload(&mut self, upload: crate::AttachmentUpload, reply: Responder) {
691723 let Some(room) = self.client.get_room(upload.timeline_id.room_id()) else {
692724 return reply.done(Err(Error::room_unknown()));
Added file: src/engine/prepare.rs+47 additions
@@ -0,0 +1,47 @@
1+use std::collections::HashMap;
2+
3+use futures_util::{
4+ FutureExt,
5+ future::{Shared, WeakShared},
6+};
7+use matrix_sdk::Client;
8+use ruma::OwnedRoomId;
9+
10+use super::executor::EngineBoxFuture;
11+use crate::Error;
12+
13+type Flight = Shared<EngineBoxFuture<'static, Result<(), Error>>>;
14+
15+/// one warm-up per room at a time, later asks join the one in flight
16+#[derive(Default)]
17+pub(super) struct Preparations(
18+ HashMap<OwnedRoomId, WeakShared<EngineBoxFuture<'static, Result<(), Error>>>>,
19+);
20+
21+impl Preparations {
22+ pub(super) fn join(&mut self, client: &Client, room_id: OwnedRoomId) -> Flight {
23+ self.0.retain(|_, flight| flight.upgrade().is_some());
24+ if let Some(flight) = self.0.get(&room_id).and_then(WeakShared::upgrade) {
25+ return flight;
26+ }
27+ let work: EngineBoxFuture<'static, _> = Box::pin(prepare(client.clone(), room_id.clone()));
28+ let flight = work.shared();
29+ if let Some(weak) = flight.downgrade() {
30+ self.0.insert(room_id, weak);
31+ }
32+ flight
33+ }
34+}
35+
36+/// the part of the sdk's pre-send encryption work it lets us reach. syncing the members
37+/// has the sdk track them all, and its own background `/keys/query` then catches up
38+/// their devices. the one time key claim and the room key share stay on the send path
39+async fn prepare(client: Client, room_id: OwnedRoomId) -> Result<(), Error> {
40+ let room = client.get_room(&room_id).ok_or_else(Error::room_unknown)?;
41+ if room.encryption_state().is_encrypted() && !room.are_members_synced() {
42+ room.sync_members()
43+ .await
44+ .map_err(|source| Error::from_sdk("syncing room members failed", source))?;
45+ }
46+ Ok(())
47+}
Modified file: src/model.rs+3 additions
@@ -113,6 +113,9 @@ pub enum RoomCommand {
113113 room_id: OwnedRoomId,
114114 typing: bool,
115115 },
116+ /// the key work a send into an encrypted room would otherwise start with, done now.
117+ /// nothing in a plain room, and a second ask joins the first
118+ PrepareToSend(OwnedRoomId),
116119 SetAccountData {
117120 room_id: OwnedRoomId,
118121 event_type: RoomAccountDataEventType,
Modified file: src/transport.rs+5 additions
@@ -360,6 +360,11 @@ impl ClientHandle {
360360 .await
361361 }
362362
363+ pub async fn prepare_to_send(&self, room_id: OwnedRoomId) -> Result<(), Error> {
364+ self.run(Command::Room(RoomCommand::PrepareToSend(room_id)))
365+ .await
366+ }
367+
363368 pub async fn join_room(
364369 &self,
365370 room: OwnedRoomOrAliasId,
Added file: tests/prepare.rs+74 additions
@@ -0,0 +1,74 @@
1+use std::time::Duration;
2+
3+use macltok::{Engine, EngineConfig};
4+use matrix_sdk::{
5+ ruma::{api::client::sync::sync_events::v5, room_id, user_id},
6+ test_utils::mocks::MatrixMockServer,
7+};
8+use matrix_sdk_test::{JoinedRoomBuilder, event_factory::EventFactory};
9+use wiremock::ResponseTemplate;
10+
11+async fn prepare_twice(encrypted: bool, member_syncs: u64) {
12+ let server = MatrixMockServer::new().await;
13+ server
14+ .mock_sliding_sync()
15+ .ok(v5::Response::new("1".to_owned()))
16+ .mount()
17+ .await;
18+ let client = server.client_builder().build().await;
19+ let room_id = room_id!("!prepare:localhost");
20+ let factory = EventFactory::new().room(room_id);
21+ let mut room = JoinedRoomBuilder::new(room_id).add_state_event(
22+ factory
23+ .member(user_id!("@example:localhost"))
24+ .into_raw_sync_state(),
25+ );
26+ if encrypted {
27+ room = room.add_state_event(
28+ factory
29+ .room_encryption()
30+ .sender(user_id!("@example:localhost"))
31+ .into_raw_sync_state(),
32+ );
33+ }
34+ server.sync_room(&client, room).await;
35+ let member: matrix_sdk::ruma::serde::Raw<
36+ matrix_sdk::ruma::events::room::member::RoomMemberEvent,
37+ > = factory.member(user_id!("@example:localhost")).into_raw();
38+ server
39+ .mock_get_members()
40+ .respond_with(
41+ ResponseTemplate::new(200)
42+ .set_body_json(serde_json::json!({ "chunk": [member] }))
43+ .set_delay(Duration::from_millis(300)),
44+ )
45+ .expect(member_syncs)
46+ .mount()
47+ .await;
48+ let config = EngineConfig::default()
49+ .with_initial_history_limit(0)
50+ .with_prepare_to_send(false);
51+ let (engine, handle, mut events) = Engine::from_client(client, config).await.unwrap();
52+ let running = tokio::spawn(engine.run());
53+ let draining = tokio::spawn(async move { while events.recv().await.is_some() {} });
54+ let (first, second) = tokio::join!(
55+ handle.prepare_to_send(room_id.to_owned()),
56+ handle.prepare_to_send(room_id.to_owned()),
57+ );
58+ first.unwrap();
59+ second.unwrap();
60+ handle.shutdown().await.unwrap();
61+ running.await.unwrap().unwrap();
62+ draining.await.unwrap();
63+ server.server().verify().await;
64+}
65+
66+#[tokio::test]
67+async fn two_prepares_at_once_sync_members_once() {
68+ prepare_twice(true, 1).await;
69+}
70+
71+#[tokio::test]
72+async fn a_plain_room_needs_no_preparing() {
73+ prepare_twice(false, 0).await;
74+}