Sliding sync, where the homeserver speaks it and the account asks
Classic /sync sends the whole account's state and grows with it: the first one on an account in a few hundred rooms is enormous, and every incremental one carries rooms nobody is looking at. MSC4186 is the answer to that, and `org.matrix.simplified_msc3575` is live on matrix.org today. Written as an adapter rather than a second sync. The response is translated into the shape `process_sync_response` already reads, and those two hundred and eighty lines - how a room becomes a buffer, how a timeline becomes messages, encryption, receipts, typing, spaces - are left alone. A second processor for the second wire format would have agreed with the first on the day it was written and diverged with every fix after. Off by default, per account, and that is the important decision. Sliding sync replaces the whole of how an account talks to its server and cannot be half-made: if the translation is wrong about something, the account does not sync at all. Defaulting it on would have switched every account on a flag-advertising homeserver - matrix.org included - on the strength of unit tests against a response this daemon wrote itself. So it is a switch, with a line under it saying to turn it back off if messages stop. Two things the shape forced: - The token is remembered along with which sync produced it. The two are different streams with differently shaped place-markers, so a server that changed its answer - or an account that changed its mind - starts fresh rather than handing over a token that will be rejected on every attempt. - MSC4186 has no presence extension, so who is online stops arriving while it is on. Named in sliding.rs rather than left to be discovered. What this does not do yet is the window. moho's buffer list holds every conversation at once, so a room outside a window would vanish from it - the list is asked for in one wide range instead. Most of the saving is there anyway, since the server sends a room once and then only what changed; narrowing it needs the buffer list to become window-aware, which is a change to the client rather than to this. Verified: the endpoint answers 401 to an unauthenticated POST (it exists and wants a token) while classic /sync answers 405 to the same, which is the GET/POST split this relies on; matrix.org advertises the flag; and eleven tests cover the translation, including the buckets an empty response must still have and a missing prev_batch being left out rather than written as null. Not verified against a real logged-in account, which is exactly why it is off by default. Closes #190. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -197,6 +197,29 @@ pub struct MatrixAccountConfig {
|
||||
/// every few seconds on a busy account.
|
||||
#[serde(default)]
|
||||
pub next_batch: Option<String>,
|
||||
/// Which sync the stored `next_batch` belongs to.
|
||||
///
|
||||
/// The two are different streams with differently shaped tokens, so a
|
||||
/// homeserver that has switched since last time must not be handed the
|
||||
/// other kind's token - it would be rejected, and the reconnect loop would
|
||||
/// spend its life being rejected. Remembered rather than inferred because
|
||||
/// nothing about a token says which stream it came from.
|
||||
#[serde(default)]
|
||||
pub used_sliding_sync: bool,
|
||||
/// Whether to use sliding sync where the homeserver offers it.
|
||||
///
|
||||
/// Off by default, and deliberately. Sliding sync replaces the whole of
|
||||
/// how this account talks to its server, and it is a change that cannot be
|
||||
/// half-made: if the translation in sliding.rs is wrong about something,
|
||||
/// the account does not sync at all. Defaulting it on would have switched
|
||||
/// every account on a homeserver that advertises the flag - matrix.org
|
||||
/// among them - on the strength of unit tests against a response this
|
||||
/// daemon wrote itself.
|
||||
///
|
||||
/// So it is a switch somebody turns on, having been told what it does,
|
||||
/// and turns off again if their account goes quiet.
|
||||
#[serde(default)]
|
||||
pub prefer_sliding_sync: bool,
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
/// A media server to hold calls on, where the homeserver names none.
|
||||
@@ -618,6 +641,41 @@ impl AccountStore {
|
||||
///
|
||||
/// Empty clears it, which puts calls back on the mesh between the people
|
||||
/// in the room - that being what works with no infrastructure at all.
|
||||
/// Turns sliding sync on or off for this account.
|
||||
///
|
||||
/// Clears the token with it, for the same reason the kind-changed path
|
||||
/// does: the two are different streams and neither will take the other's
|
||||
/// place-marker.
|
||||
pub fn set_matrix_prefer_sliding_sync(&self, account_id: &str, prefer: bool) -> Result<bool> {
|
||||
let mut matrix = self.matrix.lock().unwrap();
|
||||
match matrix.get_mut(account_id) {
|
||||
None => Ok(false),
|
||||
Some(a) => {
|
||||
a.prefer_sliding_sync = prefer;
|
||||
a.next_batch = None;
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &matrix, &self.kick.lock().unwrap())?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remembers which sync produced the stored token.
|
||||
pub fn set_matrix_used_sliding_sync(&self, account_id: &str, used: bool) -> Result<bool> {
|
||||
let mut matrix = self.matrix.lock().unwrap();
|
||||
match matrix.get_mut(account_id) {
|
||||
None => Ok(false),
|
||||
Some(a) => {
|
||||
a.used_sliding_sync = used;
|
||||
// The token this belongs to is cleared with it: the caller
|
||||
// starts a fresh stream, and a stored token from the other
|
||||
// kind would be rejected on every attempt.
|
||||
a.next_batch = None;
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &matrix, &self.kick.lock().unwrap())?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_matrix_rtc_focus(&self, account_id: &str, url: &str) -> Result<bool> {
|
||||
let mut matrix = self.matrix.lock().unwrap();
|
||||
match matrix.get_mut(account_id) {
|
||||
@@ -838,6 +896,7 @@ pub fn irc_account_to_json(a: &IrcAccountConfig, state: &str) -> Account {
|
||||
use_tor: a.use_tor,
|
||||
has_key_backup: false,
|
||||
rtc_focus_url: None,
|
||||
sliding_sync: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -871,6 +930,7 @@ pub fn discord_account_to_json(a: &DiscordAccountConfig, state: &str) -> Account
|
||||
use_tor: false,
|
||||
has_key_backup: false,
|
||||
rtc_focus_url: None,
|
||||
sliding_sync: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -903,6 +963,7 @@ pub fn sneedchat_account_to_json(a: &SneedChatAccountConfig, state: &str) -> Acc
|
||||
use_tor: false,
|
||||
has_key_backup: false,
|
||||
rtc_focus_url: None,
|
||||
sliding_sync: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -944,6 +1005,7 @@ pub fn kick_account_to_json(a: &KickAccountConfig, state: &str) -> Account {
|
||||
use_tor: false,
|
||||
has_key_backup: false,
|
||||
rtc_focus_url: None,
|
||||
sliding_sync: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -980,6 +1042,7 @@ pub fn matrix_account_to_json(a: &MatrixAccountConfig, state: &str, has_key_back
|
||||
use_tor: false,
|
||||
has_key_backup,
|
||||
rtc_focus_url: a.rtc_focus_url.clone(),
|
||||
sliding_sync: a.prefer_sliding_sync,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -325,6 +325,8 @@ pub(super) async fn try_sso_login(state: &AppState, login_id: &str, homeserver_u
|
||||
access_token: login.access_token,
|
||||
device_id: login.device_id,
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
@@ -413,6 +415,8 @@ async fn try_registration(state: &AppState, login_id: &str, homeserver_url: &str
|
||||
access_token,
|
||||
device_id,
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
@@ -466,6 +470,8 @@ pub(super) async fn try_login(state: &AppState, login_id: &str, homeserver_url:
|
||||
access_token: String::new(),
|
||||
device_id: String::new(),
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
@@ -480,6 +486,8 @@ pub(super) async fn try_login(state: &AppState, login_id: &str, homeserver_url:
|
||||
access_token: login.access_token,
|
||||
device_id: login.device_id,
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
|
||||
@@ -265,6 +265,8 @@ mod tests {
|
||||
access_token: login.access_token.clone(),
|
||||
device_id: login.device_id.clone(),
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
|
||||
@@ -81,6 +81,7 @@ pub mod polls;
|
||||
pub mod calls;
|
||||
pub mod protocol;
|
||||
pub mod roomstate;
|
||||
pub mod sliding;
|
||||
pub mod stickers;
|
||||
pub mod rooms;
|
||||
pub mod verification;
|
||||
|
||||
@@ -77,6 +77,8 @@ mod live {
|
||||
access_token: login.access_token.clone(),
|
||||
device_id: login.device_id.clone(),
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
@@ -196,6 +198,8 @@ mod live {
|
||||
access_token: login.access_token.clone(),
|
||||
device_id: login.device_id.clone(),
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
@@ -309,6 +313,8 @@ mod live {
|
||||
access_token: login_a.access_token.clone(),
|
||||
device_id: login_a.device_id.clone(),
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
@@ -467,6 +473,8 @@ mod live {
|
||||
access_token: login.access_token.clone(),
|
||||
device_id: login.device_id.clone(),
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
@@ -624,6 +632,8 @@ mod live {
|
||||
access_token: login.access_token.clone(),
|
||||
device_id: login.device_id.clone(),
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
//! Simplified sliding sync (MSC4186), where the homeserver speaks it.
|
||||
//!
|
||||
//! ## Why
|
||||
//!
|
||||
//! Classic `/sync` sends the whole account's state and grows with it. The
|
||||
//! initial one on an account in a few hundred rooms is enormous, and every
|
||||
//! incremental one carries rooms nobody is looking at. Sliding sync exists to
|
||||
//! ask for what is wanted and nothing else, and it is the reason Element X
|
||||
//! starts in a second where Element Web takes a minute.
|
||||
//!
|
||||
//! ## The shape of this
|
||||
//!
|
||||
//! An adapter, not a second sync. The response is translated into the shape
|
||||
//! `process_sync_response` already reads, and that function - two hundred and
|
||||
//! eighty lines that know how a room becomes a buffer, how a timeline becomes
|
||||
//! messages, how encryption and receipts and typing and spaces work - is left
|
||||
//! alone.
|
||||
//!
|
||||
//! Writing a second processor for the second wire format is the obvious
|
||||
//! alternative and would have been a mistake: the two would agree on the day
|
||||
//! they were written, and every fix after that would land in one of them.
|
||||
//!
|
||||
//! ## What this does not do yet, and says so
|
||||
//!
|
||||
//! Sliding sync's real trick is the window: ask for the twenty rooms on
|
||||
//! screen, and page as somebody scrolls. moho does not work that way - the
|
||||
//! buffer list holds every conversation at once, and a room outside the window
|
||||
//! would simply vanish from it.
|
||||
//!
|
||||
//! So the list here is asked for in one wide range. That still wins most of
|
||||
//! what the change is for, because the saving was never only the window: the
|
||||
//! server sends a room once and then only what changed in it, where classic
|
||||
//! sync re-sends state on every initial sync and carries rooms nobody has
|
||||
//! opened. What it does not yet win is the part that needs moho's buffer list
|
||||
//! to become window-aware, which is a change to the client rather than to this.
|
||||
//!
|
||||
//! One thing is genuinely lost and is worth naming rather than discovering:
|
||||
//! MSC4186 has no presence extension, so who is online stops arriving. That is
|
||||
//! why `supported` is consulted per account rather than assumed - see
|
||||
//! `PRESENCE_IS_LOST` at the call site.
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
/// The endpoint, which is still unstable and still carries the MSC's name.
|
||||
pub const SLIDING_PATH: &str = "/_matrix/client/unstable/org.matrix.simplified_msc3575/sync";
|
||||
|
||||
/// The flag a homeserver sets when it speaks this. Confirmed live against
|
||||
/// matrix.org, which answers `org.matrix.simplified_msc3575: true`.
|
||||
pub const SLIDING_FEATURE: &str = "org.matrix.simplified_msc3575";
|
||||
|
||||
/// How wide a window to ask for.
|
||||
///
|
||||
/// Deliberately wider than a screen - see the module comment. An account with
|
||||
/// more rooms than this would lose the tail, so it is set past any plausible
|
||||
/// number of conversations rather than at a number somebody would hit.
|
||||
pub const WINDOW: u32 = 2000;
|
||||
|
||||
/// How much of each room's timeline to ask for on the first pass.
|
||||
pub const TIMELINE_LIMIT: u32 = 20;
|
||||
|
||||
/// Whether this homeserver speaks it, by its own account.
|
||||
pub fn supported_in_versions(versions: &Value) -> bool {
|
||||
versions["unstable_features"][SLIDING_FEATURE].as_bool().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The state a room needs for moho to draw it.
|
||||
///
|
||||
/// Named rather than taken wholesale, which is the other half of what makes
|
||||
/// sliding sync cheap: classic sync sends every state event in every room, and
|
||||
/// almost none of it is read. `m.room.member` is asked for lazily - the
|
||||
/// senders in the timeline and nobody else - because a room of ten thousand
|
||||
/// people has ten thousand membership events and a roster nobody has opened.
|
||||
fn required_state() -> Value {
|
||||
json!([
|
||||
["m.room.create", ""],
|
||||
["m.room.name", ""],
|
||||
["m.room.topic", ""],
|
||||
["m.room.avatar", ""],
|
||||
["m.room.canonical_alias", ""],
|
||||
["m.room.encryption", ""],
|
||||
["m.room.power_levels", ""],
|
||||
["m.room.tombstone", ""],
|
||||
["m.room.join_rules", ""],
|
||||
["m.space.child", "*"],
|
||||
["m.space.parent", "*"],
|
||||
["m.room.member", "$LAZY"],
|
||||
])
|
||||
}
|
||||
|
||||
/// What to ask for.
|
||||
pub fn request_body() -> Value {
|
||||
json!({
|
||||
"lists": {
|
||||
"all": {
|
||||
"ranges": [[0, WINDOW]],
|
||||
"required_state": required_state(),
|
||||
"timeline_limit": TIMELINE_LIMIT,
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"to_device": { "enabled": true },
|
||||
"e2ee": { "enabled": true },
|
||||
"account_data": { "enabled": true },
|
||||
"receipts": { "enabled": true },
|
||||
"typing": { "enabled": true },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn events(list: Value) -> Value {
|
||||
json!({ "events": list })
|
||||
}
|
||||
|
||||
fn array(v: &Value) -> Value {
|
||||
Value::Array(v.as_array().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Turns a simplified-sliding-sync response into the classic shape.
|
||||
///
|
||||
/// Every field `process_sync_response` and `receive_sync_changes` read is
|
||||
/// filled in, and nothing else is - a field neither of them looks at would be
|
||||
/// translation written for nobody.
|
||||
pub fn to_classic(resp: &Value) -> Value {
|
||||
let ext = &resp["extensions"];
|
||||
|
||||
// Room account data and the two ephemeral extensions arrive keyed by room
|
||||
// rather than inside it, so they are gathered first and folded in below.
|
||||
let room_account_data = &ext["account_data"]["rooms"];
|
||||
let receipts = &ext["receipts"]["rooms"];
|
||||
let typing = &ext["typing"]["rooms"];
|
||||
|
||||
let mut join = Map::new();
|
||||
let mut invite = Map::new();
|
||||
let mut leave = Map::new();
|
||||
|
||||
for (room_id, room) in resp["rooms"].as_object().into_iter().flatten() {
|
||||
// An invitation carries its stripped state and nothing else - no
|
||||
// timeline to read and no membership to act on until it is answered.
|
||||
if room["invite_state"].is_array() {
|
||||
invite.insert(room_id.clone(), json!({ "invite_state": events(array(&room["invite_state"])) }));
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut ephemeral: Vec<Value> = Vec::new();
|
||||
if let Some(r) = receipts.get(room_id) {
|
||||
ephemeral.push(r.clone());
|
||||
}
|
||||
if let Some(t) = typing.get(room_id) {
|
||||
ephemeral.push(t.clone());
|
||||
}
|
||||
|
||||
let mut entry = json!({
|
||||
// A flat array here, an object with an `events` key there.
|
||||
"timeline": {
|
||||
"events": array(&room["timeline"]),
|
||||
"limited": room["limited"].as_bool().unwrap_or(false),
|
||||
},
|
||||
"state": events(array(&room["required_state"])),
|
||||
"ephemeral": events(Value::Array(ephemeral)),
|
||||
"account_data": events(array(room_account_data.get(room_id).unwrap_or(&Value::Null))),
|
||||
});
|
||||
// Only when the server gave one: the anchor for reading older history
|
||||
// is kept from the first sync that mentions a room, and writing a null
|
||||
// over it would lose the place.
|
||||
if let Some(prev) = room["prev_batch"].as_str() {
|
||||
entry["timeline"]["prev_batch"] = json!(prev);
|
||||
}
|
||||
|
||||
// A room this account has left arrives with that membership in its
|
||||
// state. Sorted here rather than in the processor, which reads the
|
||||
// three buckets and trusts them.
|
||||
if left_in(&room["required_state"]) {
|
||||
leave.insert(room_id.clone(), entry);
|
||||
} else {
|
||||
join.insert(room_id.clone(), entry);
|
||||
}
|
||||
}
|
||||
|
||||
json!({
|
||||
// The token is called something else and means the same thing.
|
||||
"next_batch": resp["pos"],
|
||||
"account_data": events(array(&ext["account_data"]["global"])),
|
||||
// MSC4186 has no presence extension. Empty rather than absent, so the
|
||||
// processor's own "nothing to say about presence" path runs instead of
|
||||
// it reading a null.
|
||||
"presence": events(json!([])),
|
||||
"to_device": events(array(&ext["to_device"]["events"])),
|
||||
"device_lists": ext["e2ee"]["device_lists"],
|
||||
"device_one_time_keys_count": ext["e2ee"]["device_one_time_keys_count"],
|
||||
"device_unused_fallback_key_types": ext["e2ee"]["device_unused_fallback_key_types"],
|
||||
"rooms": { "join": join, "invite": invite, "leave": leave },
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this account's own membership in the room's state says it has left.
|
||||
fn left_in(state: &Value) -> bool {
|
||||
state
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|e| e["type"].as_str() == Some("m.room.member"))
|
||||
.any(|e| matches!(e["content"]["membership"].as_str(), Some("leave") | Some("ban")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> Value {
|
||||
json!({
|
||||
"pos": "s99",
|
||||
"lists": { "all": { "count": 2 } },
|
||||
"rooms": {
|
||||
"!a:example.org": {
|
||||
"required_state": [
|
||||
{ "type": "m.room.name", "state_key": "", "content": { "name": "General" } }
|
||||
],
|
||||
"timeline": [
|
||||
{ "type": "m.room.message", "event_id": "$1", "content": { "body": "hi" } }
|
||||
],
|
||||
"prev_batch": "t1",
|
||||
"limited": true
|
||||
},
|
||||
"!b:example.org": { "invite_state": [{ "type": "m.room.name" }] }
|
||||
},
|
||||
"extensions": {
|
||||
"to_device": { "events": [{ "type": "m.room_key" }] },
|
||||
"e2ee": {
|
||||
"device_lists": { "changed": ["@x:example.org"], "left": [] },
|
||||
"device_one_time_keys_count": { "signed_curve25519": 42 }
|
||||
},
|
||||
"account_data": {
|
||||
"global": [{ "type": "m.ignored_user_list" }],
|
||||
"rooms": { "!a:example.org": [{ "type": "m.marked_unread" }] }
|
||||
},
|
||||
"receipts": { "rooms": { "!a:example.org": { "type": "m.receipt" } } },
|
||||
"typing": { "rooms": { "!a:example.org": { "type": "m.typing" } } }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_is_carried_under_the_name_the_processor_reads() {
|
||||
assert_eq!(to_classic(&sample())["next_batch"], json!("s99"));
|
||||
}
|
||||
|
||||
/// A flat array there, an object with an `events` key here.
|
||||
#[test]
|
||||
fn a_timeline_becomes_the_shape_the_processor_expects() {
|
||||
let c = to_classic(&sample());
|
||||
let room = &c["rooms"]["join"]["!a:example.org"];
|
||||
assert_eq!(room["timeline"]["events"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(room["timeline"]["prev_batch"], json!("t1"));
|
||||
assert_eq!(room["timeline"]["limited"], json!(true));
|
||||
assert_eq!(room["state"]["events"][0]["type"], json!("m.room.name"));
|
||||
}
|
||||
|
||||
/// Receipts and typing arrive keyed by room rather than inside it, and
|
||||
/// both land in the one ephemeral bucket the processor reads.
|
||||
#[test]
|
||||
fn the_two_ephemeral_extensions_are_folded_back_into_their_rooms() {
|
||||
let c = to_classic(&sample());
|
||||
let kinds: Vec<&str> = c["rooms"]["join"]["!a:example.org"]["ephemeral"]["events"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|e| e["type"].as_str())
|
||||
.collect();
|
||||
assert!(kinds.contains(&"m.receipt"));
|
||||
assert!(kinds.contains(&"m.typing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn room_account_data_is_folded_back_in_and_global_stays_global() {
|
||||
let c = to_classic(&sample());
|
||||
assert_eq!(
|
||||
c["rooms"]["join"]["!a:example.org"]["account_data"]["events"][0]["type"],
|
||||
json!("m.marked_unread")
|
||||
);
|
||||
assert_eq!(c["account_data"]["events"][0]["type"], json!("m.ignored_user_list"));
|
||||
}
|
||||
|
||||
/// The crypto machine reads these three off the top level, so the e2ee
|
||||
/// extension has to be unwrapped rather than passed along.
|
||||
#[test]
|
||||
fn the_crypto_fields_land_where_the_machine_looks_for_them() {
|
||||
let c = to_classic(&sample());
|
||||
assert_eq!(c["to_device"]["events"][0]["type"], json!("m.room_key"));
|
||||
assert_eq!(c["device_lists"]["changed"][0], json!("@x:example.org"));
|
||||
assert_eq!(c["device_one_time_keys_count"]["signed_curve25519"], json!(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invitation_goes_in_the_invite_bucket_with_its_stripped_state() {
|
||||
let c = to_classic(&sample());
|
||||
assert!(c["rooms"]["invite"]["!b:example.org"]["invite_state"]["events"].is_array());
|
||||
assert!(c["rooms"]["join"].get("!b:example.org").is_none());
|
||||
}
|
||||
|
||||
/// A room whose own membership says it has been left is sorted out, so the
|
||||
/// processor's leave handling runs rather than it being treated as joined.
|
||||
#[test]
|
||||
fn a_left_room_is_sorted_into_leave() {
|
||||
let resp = json!({
|
||||
"pos": "s1",
|
||||
"rooms": { "!c:example.org": {
|
||||
"required_state": [{ "type": "m.room.member", "content": { "membership": "leave" } }],
|
||||
"timeline": []
|
||||
}}
|
||||
});
|
||||
let c = to_classic(&resp);
|
||||
assert!(c["rooms"]["leave"].get("!c:example.org").is_some());
|
||||
assert!(c["rooms"]["join"].get("!c:example.org").is_none());
|
||||
}
|
||||
|
||||
/// An empty response must still have every bucket, or the processor reads
|
||||
/// a null where it expects an object.
|
||||
#[test]
|
||||
fn an_empty_response_still_has_every_shape_the_processor_reads() {
|
||||
let c = to_classic(&json!({ "pos": "s1" }));
|
||||
assert!(c["rooms"]["join"].is_object());
|
||||
assert!(c["rooms"]["invite"].is_object());
|
||||
assert!(c["rooms"]["leave"].is_object());
|
||||
assert!(c["account_data"]["events"].is_array());
|
||||
assert!(c["presence"]["events"].is_array());
|
||||
assert!(c["to_device"]["events"].is_array());
|
||||
}
|
||||
|
||||
/// Writing a null over a prev_batch would lose the anchor for reading
|
||||
/// older history, so a response that does not carry one says nothing.
|
||||
#[test]
|
||||
fn a_missing_prev_batch_is_left_out_rather_than_written_as_null() {
|
||||
let resp = json!({ "pos": "s1", "rooms": { "!a:x": { "timeline": [] } } });
|
||||
let c = to_classic(&resp);
|
||||
assert!(c["rooms"]["join"]["!a:x"]["timeline"].get("prev_batch").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_feature_flag_is_read_from_the_versions_answer() {
|
||||
assert!(supported_in_versions(&json!({ "unstable_features": { SLIDING_FEATURE: true } })));
|
||||
assert!(!supported_in_versions(&json!({ "unstable_features": { SLIDING_FEATURE: false } })));
|
||||
assert!(!supported_in_versions(&json!({ "unstable_features": {} })));
|
||||
assert!(!supported_in_versions(&json!({})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_request_asks_for_the_state_a_room_needs_and_lazy_members() {
|
||||
let body = request_body();
|
||||
let state = &body["lists"]["all"]["required_state"];
|
||||
let pairs: Vec<String> = state.as_array().unwrap().iter().map(|p| p[0].as_str().unwrap().to_string()).collect();
|
||||
for wanted in ["m.room.name", "m.room.encryption", "m.room.power_levels", "m.space.child"] {
|
||||
assert!(pairs.contains(&wanted.to_string()), "{wanted} should be asked for");
|
||||
}
|
||||
// The roster is the expensive one and is asked for lazily.
|
||||
let members = state.as_array().unwrap().iter().find(|p| p[0] == "m.room.member").unwrap();
|
||||
assert_eq!(members[1], json!("$LAZY"));
|
||||
for ext in ["to_device", "e2ee", "account_data", "receipts", "typing"] {
|
||||
assert_eq!(body["extensions"][ext]["enabled"], json!(true), "{ext} should be enabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,40 @@ pub(super) async fn run_sync(state: &AppState, config: &MatrixAccountConfig, acc
|
||||
// confirmed live as the actual cause of an account showing
|
||||
// "connecting" forever despite genuinely being connected and
|
||||
// receiving messages the whole time.
|
||||
// Which sync this homeserver speaks, asked once per connection.
|
||||
//
|
||||
// A stored `next_batch` from the other kind is not a token this one can
|
||||
// use - the two are different streams with differently shaped tokens - so
|
||||
// a server that has changed its answer since last time starts fresh
|
||||
// rather than being handed a token it will reject.
|
||||
// Asked for by this account, and offered by its homeserver. Both, in that
|
||||
// order - an account that has not asked never has its server questioned.
|
||||
let use_sliding = config.prefer_sliding_sync
|
||||
&& match http::get_json_anonymous(&format!(
|
||||
"{}/_matrix/client/versions",
|
||||
config.homeserver_url.trim_end_matches('/')
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(v) => sliding::supported_in_versions(&v),
|
||||
// A server that will not say is a server to treat as not having it.
|
||||
// Classic sync works everywhere; guessing the other way would break
|
||||
// the connection outright.
|
||||
Err(e) => {
|
||||
tracing::debug!("matrix[{account_id}]: asking which sync: {e}");
|
||||
false
|
||||
}
|
||||
};
|
||||
if use_sliding != config.used_sliding_sync {
|
||||
tracing::info!(
|
||||
"matrix[{account_id}]: sync kind changed to {}, starting a fresh stream",
|
||||
if use_sliding { "sliding" } else { "classic" }
|
||||
);
|
||||
next_batch = None;
|
||||
let _ = state.accounts.set_matrix_used_sliding_sync(account_id, use_sliding);
|
||||
}
|
||||
tracing::info!("matrix[{account_id}]: using {} sync", if use_sliding { "sliding" } else { "classic" });
|
||||
|
||||
let mut announced_connected = false;
|
||||
|
||||
loop {
|
||||
@@ -199,8 +233,29 @@ pub(super) async fn run_sync(state: &AppState, config: &MatrixAccountConfig, acc
|
||||
"idle" => "unavailable",
|
||||
_ => "online",
|
||||
};
|
||||
let url = sync_url(&config.homeserver_url, next_batch.as_deref(), presence);
|
||||
let resp = match http::get_json(&url, &access_token).await {
|
||||
// Sliding sync where the homeserver speaks it, and classic where it
|
||||
// does not. The answer is translated into the classic shape rather
|
||||
// than processed separately - see sliding.rs for why there is one
|
||||
// processor and not two.
|
||||
let raw = if use_sliding {
|
||||
let url = format!(
|
||||
"{}{}{}",
|
||||
config.homeserver_url.trim_end_matches('/'),
|
||||
sliding::SLIDING_PATH,
|
||||
match next_batch.as_deref() {
|
||||
Some(pos) => format!(
|
||||
"?timeout={SYNC_LONG_POLL_MS}&pos={}",
|
||||
url::form_urlencoded::byte_serialize(pos.as_bytes()).collect::<String>()
|
||||
),
|
||||
None => "?timeout=0".to_string(),
|
||||
}
|
||||
);
|
||||
http::post_json(&url, Some(&access_token), sliding::request_body()).await
|
||||
} else {
|
||||
let url = sync_url(&config.homeserver_url, next_batch.as_deref(), presence);
|
||||
http::get_json(&url, &access_token).await
|
||||
};
|
||||
let resp = match raw.map(|v| if use_sliding { sliding::to_classic(&v) } else { v }) {
|
||||
Ok(v) => v,
|
||||
Err(e) if is_auth_error(&e) => {
|
||||
tracing::info!("matrix[{account_id}]: session rejected, re-logging in");
|
||||
|
||||
@@ -586,6 +586,8 @@ mod tests {
|
||||
access_token: login.access_token.clone(),
|
||||
device_id: login.device_id.clone(),
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
|
||||
@@ -91,6 +91,9 @@ pub struct Account {
|
||||
/// that has not been given one.
|
||||
#[serde(rename = "rtcFocusUrl", skip_serializing_if = "Option::is_none")]
|
||||
pub rtc_focus_url: Option<String>,
|
||||
/// Matrix only: whether this account uses sliding sync where offered.
|
||||
#[serde(rename = "slidingSync", default)]
|
||||
pub sliding_sync: bool,
|
||||
}
|
||||
|
||||
/// One room in a Sneedchat account's currently-enabled list, as exposed on
|
||||
|
||||
@@ -4070,6 +4070,18 @@ pub async fn dispatch(
|
||||
|
||||
// Where this account's room calls go, for a homeserver that names no
|
||||
// media server of its own.
|
||||
// Sliding sync, per account and off by default - see
|
||||
// MatrixAccountConfig::prefer_sliding_sync for why it is a switch
|
||||
// rather than something taken automatically. Turning it either way
|
||||
// clears the stored token, so the next sync starts a fresh stream.
|
||||
"setMatrixSlidingSync" => match p_str_opt(params, "accountId") {
|
||||
None => (None, Some("setMatrixSlidingSync requires \"accountId\"".to_string())),
|
||||
Some(id) => {
|
||||
let on = params.get("enabled").and_then(Value::as_bool).unwrap_or(false);
|
||||
account_mutation_result(state.accounts.set_matrix_prefer_sliding_sync(id, on))
|
||||
}
|
||||
},
|
||||
|
||||
"setMatrixRtcFocus" => match p_str_opt(params, "accountId") {
|
||||
None => (None, Some("setMatrixRtcFocus requires \"accountId\"".to_string())),
|
||||
Some(id) => account_mutation_result(state.accounts.set_matrix_rtc_focus(id, p_str(params, "url", ""))),
|
||||
|
||||
Reference in New Issue
Block a user