Commit 38c4caecdb3d
tarball7 files changed+198-1
@@ -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 it3737
went3838
39
+`prepare_to_send` does the key work a send into an encrypted room would start40
+with, ahead of time, so the send itself is quicker. the engine runs it itself41
+when a room opens or `set_typing(room, true)` comes in, unless42
+`EngineConfig::with_prepare_to_send(false)`. only part of that work is reachable43
+without a matrix-sdk fork, `docs/design-decisions.md` says which44
+3945
start a timeline from `TimelineReset`, then apply each `TimelineChanged` whole4046
with `TimelineDiff::try_apply_all`. snapshots are `imbl::Vector`s, re-exported4147
as `macltok::imbl`, so a copy costs nothing. when a batch doesn't fit none of it@@ -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 just114114
cancels it115115
116
+## warming up to send117
+118
+the sdk does all its key work on the send path, in the room's send queue before119
+the `/send` itself. members synced, a `/keys/query` for anyone untracked or120
+dirty, then a `/keys/claim` and the room key sent to every device. over a slow121
+federation that's a minute of nothing. `prepare_to_send` does what the public122
+api lets us of that, ahead of time, which comes down to syncing the members if123
+they aren't. the sdk dedups that with its own sync, and it marks every member124
+tracked and dirty, so the sdk's background outgoing requests run the125
+`/keys/query` long before the send would have. that's what moves the query off126
+the send path. we don't ask `request_user_identity` for anyone, since that's127
+an out of band query that never marks a user tracked or up to date128
+(matrix-sdk-crypto `identities/manager.rs` ~804-817), federated traffic the129
+send path would redo anyway. a synced room costs nothing, so there's no130
+cooldown. one warm-up per room at a time, a second ask joins it. opening a room131
+or starting to type runs one too, unless `EngineConfig::prepare_to_send` is132
+off. it's on because malice asked, not because anyone decided133
+134
+that's the leftovers of turning down a matrix-sdk fork. the `/keys/claim` and135
+the room key share, the slow federated half, stay on the send path. the136
+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 send138
+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 in140
+141
+the hook upstream is the todo above `preshare_room_key` at matrix-sdk142
+`room/mod.rs` ~2392, asking for exactly this. it's still private on main and in143
+0.19.1 as of 2026-09-26. once upstream makes it public, or a fork becomes144
+acceptable, `prepare_to_send` should just call it and most of this goes145
+116146
## mutations117147
118148
membership, moderation, power levels, room creation and account data writes go@@ -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 it51
+ pub prepare_to_send: bool,4952
}5053
5154
impl EngineConfig {@@ -78,6 +81,12 @@ impl EngineConfig {
7881
self.max_media_sources = sources;7982
self8083
}84
+85
+ #[must_use]86
+ pub const fn with_prepare_to_send(mut self, prepare: bool) -> Self {87
+ self.prepare_to_send = prepare;88
+ self89
+ }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).await641653
});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).await648672
});@@ -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.tasks717
+ .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()));@@ -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 flight16
+#[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
+ flight33
+ }34
+}35
+36
+/// the part of the sdk's pre-send encryption work it lets us reach. syncing the members37
+/// has the sdk track them all, and its own background `/keys/query` then catches up38
+/// their devices. the one time key claim and the room key share stay on the send path39
+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
+ .await44
+ .map_err(|source| Error::from_sdk("syncing room members failed", source))?;45
+ }46
+ Ok(())47
+}@@ -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 first118
+ PrepareToSend(OwnedRoomId),116119
SetAccountData {117120
room_id: OwnedRoomId,118121
event_type: RoomAccountDataEventType,@@ -360,6 +360,11 @@ impl ClientHandle {
360360
.await361361
}362362
363
+ pub async fn prepare_to_send(&self, room_id: OwnedRoomId) -> Result<(), Error> {364
+ self.run(Command::Room(RoomCommand::PrepareToSend(room_id)))365
+ .await366
+ }367
+363368
pub async fn join_room(364369
&self,365370
room: OwnedRoomOrAliasId,@@ -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
+ server14
+ .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
+ factory23
+ .member(user_id!("@example:localhost"))24
+ .into_raw_sync_state(),25
+ );26
+ if encrypted {27
+ room = room.add_state_event(28
+ factory29
+ .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
+ server39
+ .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
+}