Merge branch 'develop' into madlittlemods/rust-db-access-using-python-db-pool

Conflicts:
	rust/src/lib.rs
	synapse/storage/util/id_generators.py
This commit is contained in:
Eric Eastwood
2026-06-09 17:52:16 -05:00
53 changed files with 1051 additions and 196 deletions
+26
View File
@@ -1,3 +1,29 @@
# Synapse 1.155.0rc1 (2026-06-09)
## Bugfixes
- Limit the to-device EDU size to a reasonable value to mitigate long queues of to-device messages preventing outgoing federation because of the size of the transaction. ([\#19617](https://github.com/element-hq/synapse/issues/19617))
- Work around bug that sometimes breaks joining restricted rooms that require a remote join. Contributed by @tulir @ Beeper. ([\#19730](https://github.com/element-hq/synapse/issues/19730))
- Update Sliding Sync to return a new response immediately if a room subscription has changed and produced a new response. ([\#19734](https://github.com/element-hq/synapse/issues/19734), [\#19792](https://github.com/element-hq/synapse/issues/19792))
- Fix the `/capabilities` endpoint returning a 500 error on non-media workers when [MSC4452: Preview URL capabilities API](https://github.com/matrix-org/matrix-spec-proposals/pull/4452) is enabled. ([\#19839](https://github.com/element-hq/synapse/issues/19839))
## Improved Documentation
- Document how to see Rust build failure output when using `poetry install`. ([\#19818](https://github.com/element-hq/synapse/issues/19818))
- Document that the SQLite version included in Ubuntu LTS, aside from ESM-only versions, is included in our support policy. ([\#19823](https://github.com/element-hq/synapse/issues/19823))
## Internal Changes
- Port the Python Event classes to Rust. ([\#19701](https://github.com/element-hq/synapse/issues/19701), [\#19816](https://github.com/element-hq/synapse/issues/19816), [\#19817](https://github.com/element-hq/synapse/issues/19817), [\#19819](https://github.com/element-hq/synapse/issues/19819))
- Added tests to ensure that email notification links are sanitized. Contributed by Noah Markert. ([\#19741](https://github.com/element-hq/synapse/issues/19741))
- Add `GcpJsonFormatter` logging formatter for use with Google Cloud Logging and GKE deployments. ([\#19775](https://github.com/element-hq/synapse/issues/19775))
- Add more logging to the to-device message replication stream. ([\#19801](https://github.com/element-hq/synapse/issues/19801), [\#19821](https://github.com/element-hq/synapse/issues/19821))
- Port `Requester` class to Rust. ([\#19828](https://github.com/element-hq/synapse/issues/19828))
# Synapse 1.154.0 (2026-06-04)
No significant changes since 1.154.0rc1.
# Synapse 1.154.0rc1 (2026-05-27)
## Features
-1
View File
@@ -1 +0,0 @@
A long queue of to-device messages could prevent outgoing federation because of the size of the transaction, let's limit the to-device EDU size to a reasonable value.
-1
View File
@@ -1 +0,0 @@
Port the python Event classes to Rust.
-1
View File
@@ -1 +0,0 @@
Work around bug that sometimes breaks joining restricted rooms that require a remote join. Contributed by @tulir @ Beeper.
-1
View File
@@ -1 +0,0 @@
Added tests to ensure that email notification links are sanitized. Contributed by Noah Markert.
-1
View File
@@ -1 +0,0 @@
Add `GcpJsonFormatter` logging formatter for use with Google Cloud Logging and GKE deployments.
-1
View File
@@ -1 +0,0 @@
Add more logging to the to-device message replication stream.
-1
View File
@@ -1 +0,0 @@
Port the python Event classes to Rust.
-1
View File
@@ -1 +0,0 @@
Port the python Event classes to Rust.
-1
View File
@@ -1 +0,0 @@
Document how to see Rust build failure output when using `poetry install`.
-1
View File
@@ -1 +0,0 @@
Port the python Event classes to Rust.
+12
View File
@@ -1,3 +1,15 @@
matrix-synapse-py3 (1.155.0~rc1) stable; urgency=medium
* New Synapse release 1.155.0rc1.
-- Synapse Packaging team <packages@matrix.org> Tue, 09 Jun 2026 14:58:03 +0100
matrix-synapse-py3 (1.154.0) stable; urgency=medium
* New Synapse release 1.154.0.
-- Synapse Packaging team <packages@matrix.org> Thu, 04 Jun 2026 14:16:23 +0100
matrix-synapse-py3 (1.154.0~rc1) stable; urgency=medium
* New Synapse release 1.154.0rc1.
+1 -1
View File
@@ -22,7 +22,7 @@ people building from source should ensure they can fetch recent versions of Rust
The oldest supported version of SQLite is the version
[provided](https://packages.debian.org/oldstable/libsqlite3-0) by
[Debian oldstable](https://wiki.debian.org/DebianOldStable).
[Debian oldstable](https://wiki.debian.org/DebianOldStable) or the oldest maintenance/security-supported [Ubuntu LTS](https://endoflife.date/ubuntu) (Ubuntu versions with only Expanded Security Maintenance are not included).
### Context
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "matrix-synapse"
version = "1.154.0rc1"
version = "1.155.0rc1"
description = "Homeserver for the Matrix decentralised comms protocol"
readme = "README.rst"
authors = [
+2
View File
@@ -22,6 +22,7 @@ pub mod rendezvous;
pub mod room_versions;
pub mod segmenter;
pub mod storage;
pub mod types;
lazy_static! {
static ref LOGGING_HANDLE: ResetHandle = pyo3_log::init();
@@ -75,6 +76,7 @@ fn synapse_rust(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
msc4388_rendezvous::register_module(py, m)?;
segmenter::register_module(py, m)?;
room_versions::register_module(py, m)?;
types::register_module(py, m)?;
Ok(())
}
+340
View File
@@ -0,0 +1,340 @@
/*
* This file is licensed under the Affero General Public License (AGPL) version 3.
*
* Copyright (C) 2026 Element Creations Ltd
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* See the GNU Affero General Public License for more details:
* <https://www.gnu.org/licenses/agpl-3.0.html>.
*
*/
//! Rust implementations of types from `synapse.types`.
use std::collections::HashSet;
use once_cell::sync::OnceCell;
use pyo3::prelude::*;
use pyo3::{
exceptions::PyKeyError,
types::{PyDict, PyDictMethods, PyList},
};
/// A reference to the `synapse.types.UserID` class.
static USER_ID_CLASS: OnceCell<Py<PyAny>> = OnceCell::new();
/// Access to the `synapse.types.UserID` class.
fn user_id_class(py: Python<'_>) -> PyResult<&Bound<'_, PyAny>> {
Ok(USER_ID_CLASS
.get_or_try_init(|| -> PyResult<_> {
Ok(py.import("synapse.types")?.getattr("UserID")?.unbind())
})?
.bind(py))
}
/// Represents the user making a request.
#[pyclass(frozen, skip_from_py_object, get_all, eq)]
#[derive(Debug, PartialEq, Eq)]
pub struct Requester {
/// The ID of the user making the request, in string form (see
/// [`Self::user`] for accessing the parsed `UserID`).
user_id: String,
/// The ID of the access token used for this request, or None for
/// appservices, guests, and tokens generated by the admin API
access_token_id: Option<i64>,
/// True if the user making this request is a guest
is_guest: bool,
/// Any scopes associated with the access token used for this request, or an
/// empty set if no token or a non-oauth token was used
scope: HashSet<String>,
/// True if the user making this request is shadow banned
shadow_banned: bool,
/// The device_id which was set at authentication time, or None for
/// appservices, guests, and tokens generated by the admin API
device_id: Option<String>,
/// The ID of the AS requesting on behalf of the user, or None.
app_service_id: Option<String>,
/// The entity that authenticated when making the request.
///
/// This is different to the `user_id` when an admin user or the server is
/// "puppeting" the user.
authenticated_entity: String,
}
#[pymethods]
impl Requester {
#[new]
#[pyo3(signature = (
user,
access_token_id,
is_guest,
scope,
shadow_banned,
device_id,
app_service_id,
authenticated_entity,
))]
#[allow(clippy::too_many_arguments)]
fn new(
user: &Bound<'_, PyAny>,
access_token_id: Option<i64>,
is_guest: &Bound<'_, PyAny>,
scope: HashSet<String>,
shadow_banned: &Bound<'_, PyAny>,
device_id: Option<String>,
app_service_id: Option<String>,
authenticated_entity: String,
) -> PyResult<Self> {
// The `user` argument should be a `UserID`, which has a `to_string` for
// getting the string form.
let user_id = user.call_method0("to_string")?.extract::<String>()?;
// The `is_guest` and `shadow_banned` arguments are expected to be
// Python bools, but unfortunately Synapse often passes them as truthy
// values (mainly due to reading from SQLite, which returns 0/1 for
// bools).
let is_guest = is_guest.is_truthy()?;
let shadow_banned = shadow_banned.is_truthy()?;
Ok(Requester {
user_id,
access_token_id,
is_guest,
scope,
shadow_banned,
device_id,
app_service_id,
authenticated_entity,
})
}
/// The user making the request, as a Python `UserID`.
#[getter]
fn user<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
user_id_class(py)?.call_method1("from_string", (&self.user_id,))
}
/// Converts self to a type that can be serialized as JSON, and then
/// deserialized by [`Self::deserialize`]
fn serialize<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
let dict = PyDict::new(py);
dict.set_item("user_id", &self.user_id)?;
dict.set_item("access_token_id", self.access_token_id)?;
dict.set_item("is_guest", self.is_guest)?;
dict.set_item("scope", PyList::new(py, &self.scope)?)?;
dict.set_item("shadow_banned", self.shadow_banned)?;
dict.set_item("device_id", self.device_id.as_deref())?;
// NB: the wire key is "app_server_id" (server, not service). Changing
// this is non-trivial as it would break replication during a rolling
// upgrade.
dict.set_item("app_server_id", self.app_service_id.as_deref())?;
dict.set_item("authenticated_entity", &self.authenticated_entity)?;
Ok(dict)
}
/// Converts a dict that was produced by [`Self::serialize`] back into a
/// [`Requester`].
#[staticmethod]
fn deserialize(py: Python<'_>, input: &Bound<'_, PyAny>) -> PyResult<Self> {
let user_id = input.get_item("user_id")?.extract::<String>()?;
let access_token_id = input
.get_item("access_token_id")?
.extract::<Option<i64>>()?;
let is_guest = input.get_item("is_guest")?.is_truthy()?;
// `serialize` stores the scope as a list, so extract it as a `Vec`
// (which accepts any sequence) and collect into a set. For backwards
// compatibility, "scope" is optional and defaults to an empty set if
// not present.
let scope = match input.get_item("scope") {
Ok(scope) => scope.extract::<Vec<String>>()?.into_iter().collect(),
Err(err) if err.is_instance_of::<PyKeyError>(py) => HashSet::new(),
Err(err) => return Err(err),
};
let shadow_banned = input.get_item("shadow_banned")?.is_truthy()?;
let device_id = input.get_item("device_id")?.extract::<Option<String>>()?;
// The wire key is "app_server_id", not "app_service_id" — see `serialize`.
let app_service_id = input
.get_item("app_server_id")?
.extract::<Option<String>>()?;
let authenticated_entity = input
.get_item("authenticated_entity")?
.extract::<String>()?;
Ok(Requester {
user_id,
access_token_id,
is_guest,
scope,
shadow_banned,
device_id,
app_service_id,
authenticated_entity,
})
}
fn __repr__(&self) -> String {
format!(
"Requester(user_id={}, access_token_id={:?}, is_guest={}, scope={:?}, \
shadow_banned={}, device_id={:?}, app_service_id={:?}, authenticated_entity={})",
self.user_id,
self.access_token_id,
self.is_guest,
self.scope,
self.shadow_banned,
self.device_id,
self.app_service_id,
self.authenticated_entity,
)
}
}
/// Called when registering modules with python.
pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
let child_module = PyModule::new(py, "types")?;
child_module.add_class::<Requester>()?;
m.add_submodule(&child_module)?;
// We need to manually add the module to sys.modules to make `from
// synapse.synapse_rust.types import Requester` work.
py.import("sys")?
.getattr("modules")?
.set_item("synapse.synapse_rust.types", child_module)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A `Requester` with every field populated, for use as a test fixture.
fn sample_requester() -> Requester {
Requester {
user_id: "@alice:example.com".to_string(),
access_token_id: Some(42),
is_guest: false,
scope: HashSet::from(["urn:matrix:client:api:*".to_string()]),
shadow_banned: false,
device_id: Some("ABCDEFG".to_string()),
app_service_id: None,
authenticated_entity: "@alice:example.com".to_string(),
}
}
#[test]
fn test_serialize() {
Python::initialize();
Python::attach(|py| -> Result<(), PyErr> {
let requester = sample_requester();
let dict = requester.serialize(py)?;
let dict = dict.as_any();
assert_eq!(
dict.get_item("user_id")?.extract::<String>()?,
"@alice:example.com"
);
assert_eq!(dict.get_item("access_token_id")?.extract::<i64>()?, 42);
assert!(!dict.get_item("is_guest")?.extract::<bool>()?);
assert_eq!(
dict.get_item("scope")?.extract::<Vec<String>>()?,
vec!["urn:matrix:client:api:*".to_string()]
);
assert!(!dict.get_item("shadow_banned")?.extract::<bool>()?);
assert_eq!(
dict.get_item("device_id")?.extract::<Option<String>>()?,
Some("ABCDEFG".to_string())
);
assert_eq!(
dict.get_item("authenticated_entity")?.extract::<String>()?,
"@alice:example.com"
);
// The `app_service_id` field is serialized under the wire key
// "app_server_id" (server, not service), and there must be no
// "app_service_id" key.
assert!(dict.get_item("app_server_id")?.is_none());
assert!(!dict.contains("app_service_id")?);
Ok(())
})
.unwrap();
}
#[test]
fn test_serialize_deserialize_round_trip() {
Python::initialize();
Python::attach(|py| {
// Use a requester that exercises the optional fields and the
// `app_service_id` -> "app_server_id" wire-key mapping.
let requester = Requester {
user_id: "@bob:example.com".to_string(),
access_token_id: None,
is_guest: true,
scope: HashSet::from(["a".to_string(), "b".to_string()]),
shadow_banned: true,
device_id: None,
app_service_id: Some("my_appservice".to_string()),
authenticated_entity: "@admin:example.com".to_string(),
};
let dict = requester.serialize(py).unwrap();
let deserialized = Requester::deserialize(py, dict.as_any()).unwrap();
let deserialized = Bound::new(py, deserialized).unwrap();
assert_eq!(&requester, deserialized.get());
});
}
#[test]
fn test_deserialize_defaults_scope_when_missing() {
Python::initialize();
Python::attach(|py| {
// An older serialized form may omit "scope"; it should default to
// an empty set rather than erroring.
let dict = PyDict::new(py);
dict.set_item("user_id", "@alice:example.com").unwrap();
dict.set_item("access_token_id", py.None()).unwrap();
dict.set_item("is_guest", false).unwrap();
dict.set_item("shadow_banned", false).unwrap();
dict.set_item("device_id", py.None()).unwrap();
dict.set_item("app_server_id", py.None()).unwrap();
dict.set_item("authenticated_entity", "@alice:example.com")
.unwrap();
let requester = Requester::deserialize(py, dict.as_any()).unwrap();
assert!(requester.scope.is_empty());
});
}
#[test]
fn test_deserialize_coerces_truthy_bools() {
Python::initialize();
Python::attach(|py| {
// SQLite returns 0/1 for booleans, so non-bool truthy values must
// be coerced for `is_guest` and `shadow_banned`.
let dict = PyDict::new(py);
dict.set_item("user_id", "@alice:example.com").unwrap();
dict.set_item("access_token_id", py.None()).unwrap();
dict.set_item("is_guest", 1).unwrap();
dict.set_item("scope", PyList::empty(py)).unwrap();
dict.set_item("shadow_banned", 0).unwrap();
dict.set_item("device_id", py.None()).unwrap();
dict.set_item("app_server_id", py.None()).unwrap();
dict.set_item("authenticated_entity", "@alice:example.com")
.unwrap();
let requester = Requester::deserialize(py, dict.as_any()).unwrap();
assert!(requester.is_guest);
assert!(!requester.shadow_banned);
});
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
$schema: https://element-hq.github.io/synapse/latest/schema/v1/meta.schema.json
$id: https://element-hq.github.io/synapse/schema/synapse/v1.154/synapse-config.schema.json
$id: https://element-hq.github.io/synapse/schema/synapse/v1.155/synapse-config.schema.json
type: object
properties:
modules:
+4 -2
View File
@@ -371,7 +371,9 @@ class BaseAuth:
"""
ip_addr = request.get_client_ip_if_available()
if ip_addr and (not requester.app_service or self._track_appservice_user_ips):
if ip_addr and (
not requester.app_service_id or self._track_appservice_user_ips
):
user_agent = get_request_user_agent(request)
access_token = self.get_access_token_from_request(request)
@@ -381,7 +383,7 @@ class BaseAuth:
# table during the transition
recorded_device_id = (
"dummy-device"
if requester.device_id is None and requester.app_service is not None
if requester.device_id is None and requester.app_service_id is not None
else requester.device_id
)
await self.store.insert_client_ip(
+2 -2
View File
@@ -106,8 +106,8 @@ class InternalAuth(BaseAuth):
parent_span.set_tag("user_id", requester.user.to_string())
if requester.device_id is not None:
parent_span.set_tag("device_id", requester.device_id)
if requester.app_service is not None:
parent_span.set_tag("appservice_id", requester.app_service.id)
if requester.app_service_id is not None:
parent_span.set_tag("appservice_id", requester.app_service_id)
return requester
async def get_user_by_req_experimental_feature(
+2 -2
View File
@@ -311,8 +311,8 @@ class MasDelegatedAuth(BaseAuth):
parent_span.set_tag("user_id", requester.user.to_string())
if requester.device_id is not None:
parent_span.set_tag("device_id", requester.device_id)
if requester.app_service is not None:
parent_span.set_tag("appservice_id", requester.app_service.id)
if requester.app_service_id is not None:
parent_span.set_tag("appservice_id", requester.app_service_id)
return requester
async def get_user_by_access_token(
+2 -2
View File
@@ -420,8 +420,8 @@ class MSC3861DelegatedAuth(BaseAuth):
parent_span.set_tag("user_id", requester.user.to_string())
if requester.device_id is not None:
parent_span.set_tag("device_id", requester.device_id)
if requester.app_service is not None:
parent_span.set_tag("appservice_id", requester.app_service.id)
if requester.app_service_id is not None:
parent_span.set_tag("appservice_id", requester.app_service_id)
return requester
async def _wrapped_get_user_by_req(
+1 -1
View File
@@ -88,7 +88,7 @@ class AuthBlocking:
# We never block the server from doing actions on behalf of
# users.
return
if requester.app_service and not self._track_appservice_user_ips:
if requester.app_service_id and not self._track_appservice_user_ips:
# If we're authenticated as an appservice then we only block
# auth if `track_appservice_user_ips` is set, as that option
# implicitly means that application services are part of MAU
+6 -1
View File
@@ -163,7 +163,12 @@ class Ratelimiter:
if requester:
# Disable rate limiting of users belonging to any AS that is configured
# not to be rate limited in its registration file (rate_limited: true|false).
if requester.app_service and not requester.app_service.is_rate_limited():
app_service = (
self.store.get_app_service_by_id(requester.app_service_id)
if requester.app_service_id
else None
)
if app_service and not app_service.is_rate_limited():
return True, -1.0
# Check if ratelimiting has been disabled for the user.
+4 -1
View File
@@ -139,6 +139,10 @@ class ContentRepositoryConfig(Config):
section = "media"
def read_config(self, config: JsonDict, **kwargs: Any) -> None:
# We need to set this configuration flag even if this worker
# is not a media repo worker, as it's exposed in `/capabilities`
self.url_preview_enabled = bool(config.get("url_preview_enabled", False))
# Only enable the media repo if either the media repo is enabled or the
# current worker app is the media repo.
if (
@@ -242,7 +246,6 @@ class ContentRepositoryConfig(Config):
self.thumbnail_requirements = parse_thumbnail_requirements(
config.get("thumbnail_sizes", DEFAULT_THUMBNAIL_SIZES)
)
self.url_preview_enabled = bool(config.get("url_preview_enabled", False))
if self.url_preview_enabled:
check_requirements("url-preview")
+1 -1
View File
@@ -419,7 +419,7 @@ def _serialize_event(
and event_token_id == config.requester.access_token_id
)
or config.requester.is_guest
or config.requester.app_service
or config.requester.app_service_id
):
d["unsigned"]["transaction_id"] = txn_id
+1 -1
View File
@@ -427,7 +427,7 @@ class AdminHandler:
r = task.params.get("requester")
assert r is not None
admin = Requester.deserialize(self._store, r)
admin = Requester.deserialize(r)
user_id = task.params.get("user_id")
assert user_id is not None
+5 -1
View File
@@ -132,7 +132,11 @@ class DirectoryHandler:
Codes.INVALID_PARAM,
)
service = requester.app_service
service = (
self.store.get_app_service_by_id(requester.app_service_id)
if requester.app_service_id
else None
)
if service:
if not service.is_room_alias_in_namespace(room_alias_str):
raise SynapseError(
+13 -9
View File
@@ -343,7 +343,7 @@ class MessageHandler:
Returns:
A dict of user_id to profile info
"""
if not requester.app_service:
if not requester.app_service_id:
# We check AS auth after fetching the room membership, as it
# requires us to pull out all joined members anyway.
membership, _ = await self.auth.check_user_in_room_or_world_readable(
@@ -365,12 +365,14 @@ class MessageHandler:
# If this is an AS, double check that they are allowed to see the members.
# This can either be because the AS user is in the room or because there
# is a user in the room that the AS is "interested in"
if (
requester.app_service
and requester.user.to_string() not in users_with_profile
):
app_service = (
self.store.get_app_service_by_id(requester.app_service_id)
if requester.app_service_id
else None
)
if app_service and requester.user.to_string() not in users_with_profile:
for uid in users_with_profile:
if requester.app_service.is_interested_in_user(uid):
if app_service.is_interested_in_user(uid):
break
else:
# Loop fell through, AS has no interested users in room
@@ -846,7 +848,7 @@ class EventCreationHandler:
return
# exempt AS users from needing consent
if requester.app_service is not None:
if requester.app_service_id is not None:
return
user_id = requester.authenticated_entity
@@ -1425,8 +1427,10 @@ class EventCreationHandler:
else:
context = await self.state.calculate_context_info(event)
if requester:
context.app_service = requester.app_service
if requester and requester.app_service_id:
context.app_service = self.store.get_app_service_by_id(
requester.app_service_id
)
res, new_content = await self._third_party_event_rules.check_event_allowed(
event, context
+2 -2
View File
@@ -661,8 +661,8 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
key = (room_id,)
as_id = object()
if requester.app_service:
as_id = requester.app_service.id
if requester.app_service_id:
as_id = requester.app_service_id
# We first linearise by the application service (to try to limit concurrent joins
# by application services), and then by room ID.
+35 -24
View File
@@ -184,34 +184,45 @@ class SlidingSyncHandler:
timeout_ms -= after_wait_ts - before_wait_ts
timeout_ms = max(timeout_ms, 0)
# We're going to respond immediately if the timeout is 0 or if this is an
# initial sync (without a `from_token`) so we can avoid calling
# `notifier.wait_for_events()`.
if timeout_ms == 0 or from_token is None:
now_token = self.event_sources.get_current_token()
result = await self.current_sync_for_user(
# Compute a response immediately. We always need to do this before
# waiting for new data (unlike in /v3/sync), as the request config might
# have changed (e.g. new room subscriptions, etc).
now_token = self.event_sources.get_current_token()
result = await self.current_sync_for_user(
sync_config,
from_token=from_token,
to_token=now_token,
)
# Return immediately if we have a result, the timeout is 0, or this is
# an initial sync.
if result or timeout_ms == 0 or from_token is None:
return result, did_wait
# Otherwise, we wait for something to happen and report it to the user.
async def current_sync_callback(
before_token: StreamToken, after_token: StreamToken
) -> SlidingSyncResult:
return await self.current_sync_for_user(
sync_config,
from_token=from_token,
to_token=now_token,
to_token=after_token,
)
else:
# Otherwise, we wait for something to happen and report it to the user.
async def current_sync_callback(
before_token: StreamToken, after_token: StreamToken
) -> SlidingSyncResult:
return await self.current_sync_for_user(
sync_config,
from_token=from_token,
to_token=after_token,
)
result = await self.notifier.wait_for_events(
sync_config.user.to_string(),
timeout_ms,
current_sync_callback,
from_token=from_token.stream_token,
)
did_wait = True
result = await self.notifier.wait_for_events(
sync_config.user.to_string(),
timeout_ms,
current_sync_callback,
# We *wait* from `now_token` as we have already computed the sync
# response up to `now_token` above, so as a minor optimization, we
# can wait for something new to arrive after `now_token`.
#
# We still generate the sync response using `from_token` in the
# callback above though, as to generate the correct response it
# needs to know the "real" `from_token`.
from_token=now_token,
)
did_wait = True
return result, did_wait
+8 -4
View File
@@ -852,11 +852,15 @@ class SlidingSyncRoomLists:
previous_connection_state.room_configs.get(room_id)
)
if prev_room_sync_config is not None:
# Always include rooms whose timeline limit has increased.
# (see the "XXX: Odd behavior" described below)
# Always include rooms whose effective config has
# expanded. This covers timeline-limit increases and
# required-state additions introduced by room
# subscriptions overriding list-derived params.
if (
prev_room_sync_config.timeline_limit
< room_config.timeline_limit
prev_room_sync_config.combine_room_sync_config(
room_config
)
!= prev_room_sync_config
):
rooms_should_send.add(room_id)
continue
+4 -4
View File
@@ -89,7 +89,7 @@ class ReplicationRemoteJoinRestServlet(ReplicationEndpoint):
remote_room_hosts = content["remote_room_hosts"]
event_content = content["content"]
requester = Requester.deserialize(self.store, content["requester"])
requester = Requester.deserialize(content["requester"])
request.requester = requester
logger.info("remote_join: %s into room: %s", user_id, room_id)
@@ -153,7 +153,7 @@ class ReplicationRemoteKnockRestServlet(ReplicationEndpoint):
remote_room_hosts = content["remote_room_hosts"]
event_content = content["content"]
requester = Requester.deserialize(self.store, content["requester"])
requester = Requester.deserialize(content["requester"])
request.requester = requester
logger.debug("remote_knock: %s on room: %s", user_id, room_id)
@@ -219,7 +219,7 @@ class ReplicationRemoteRejectInviteRestServlet(ReplicationEndpoint):
txn_id = content["txn_id"]
event_content = content["content"]
requester = Requester.deserialize(self.store, content["requester"])
requester = Requester.deserialize(content["requester"])
request.requester = requester
# hopefully we're now on the master, so this won't recurse!
@@ -283,7 +283,7 @@ class ReplicationRemoteRescindKnockRestServlet(ReplicationEndpoint):
txn_id = content["txn_id"]
event_content = content["content"]
requester = Requester.deserialize(self.store, content["requester"])
requester = Requester.deserialize(content["requester"])
request.requester = requester
# hopefully we're now on the master, so this won't recurse!
+1 -3
View File
@@ -141,9 +141,7 @@ class ReplicationSendEventsRestServlet(ReplicationEndpoint):
)
event.internal_metadata.outlier = event_payload["outlier"]
requester = Requester.deserialize(
self.store, event_payload["requester"]
)
requester = Requester.deserialize(event_payload["requester"])
context = EventContext.deserialize(
self._storage_controllers, event_payload["context"]
)
+1 -1
View File
@@ -305,7 +305,7 @@ class DeactivateAccountRestServlet(RestServlet):
# allow ASes to deactivate their own users:
# ASes don't need user-interactive auth
if not requester.app_service:
if not requester.app_service_id:
await self.auth_handler.validate_user_via_ui_auth(
requester,
request,
+12 -5
View File
@@ -55,25 +55,32 @@ class AppservicePingRestServlet(RestServlet):
self.as_api = hs.get_application_service_api()
self.scheduler = hs.get_application_service_scheduler()
self.auth = hs.get_auth()
self.store = hs.get_datastores().main
async def on_POST(
self, request: SynapseRequest, appservice_id: str
) -> tuple[int, JsonDict]:
requester = await self.auth.get_user_by_req(request)
if not requester.app_service:
app_service = (
self.store.get_app_service_by_id(requester.app_service_id)
if requester.app_service_id
else None
)
if not app_service:
raise SynapseError(
HTTPStatus.FORBIDDEN,
"Only application services can use the /appservice/ping endpoint",
Codes.FORBIDDEN,
)
elif requester.app_service.id != appservice_id:
elif app_service.id != appservice_id:
raise SynapseError(
HTTPStatus.FORBIDDEN,
"Mismatching application service ID in path",
Codes.FORBIDDEN,
)
elif not requester.app_service.url:
elif not app_service.url:
raise SynapseError(
HTTPStatus.BAD_REQUEST,
"The application service does not have a URL set",
@@ -85,11 +92,11 @@ class AppservicePingRestServlet(RestServlet):
start = time.monotonic()
try:
await self.as_api.ping(requester.app_service, txn_id)
await self.as_api.ping(app_service, txn_id)
# We got a OK response, so if the AS needs to be recovered then lets recover it now.
# This sets off a task in the background and so is safe to execute and forget.
self.scheduler.txn_ctrl.force_retry(requester.app_service)
self.scheduler.txn_ctrl.force_retry(app_service)
except RequestTimedOutError as e:
raise SynapseError(
HTTPStatus.GATEWAY_TIMEOUT,
+3 -3
View File
@@ -105,7 +105,7 @@ class DeleteDevicesRestServlet(RestServlet):
else:
raise e
if requester.app_service:
if requester.app_service_id:
# MSC4190 can skip UIA for this endpoint
pass
else:
@@ -177,7 +177,7 @@ class DeviceRestServlet(RestServlet):
else:
raise
if requester.app_service:
if requester.app_service_id:
# MSC4190 allows appservices to delete devices through this endpoint without UIA
# It's also allowed with MSC3861 enabled
pass
@@ -212,7 +212,7 @@ class DeviceRestServlet(RestServlet):
body = parse_and_validate_json_object_from_request(request, self.PutBody)
# MSC4190 allows appservices to create devices through this endpoint
if requester.app_service:
if requester.app_service_id:
created = await self.device_handler.upsert_device(
user_id=requester.user.to_string(),
device_id=device_id,
+10 -5
View File
@@ -110,14 +110,19 @@ class ClientDirectoryServer(RestServlet):
room_alias_obj = RoomAlias.from_string(room_alias)
requester = await self.auth.get_user_by_req(request)
if requester.app_service:
app_service = (
self.store.get_app_service_by_id(requester.app_service_id)
if requester.app_service_id
else None
)
if app_service:
await self.directory_handler.delete_appservice_association(
requester.app_service, room_alias_obj
app_service, room_alias_obj
)
logger.info(
"Application service at %s deleted alias %s",
requester.app_service.url,
app_service.url,
room_alias_obj.to_string(),
)
@@ -199,13 +204,13 @@ class ClientAppserviceDirectoryListServer(RestServlet):
visibility: Literal["public", "private"],
) -> tuple[int, JsonDict]:
requester = await self.auth.get_user_by_req(request)
if not requester.app_service:
if not requester.app_service_id:
raise AuthError(
403, "Only appservices can edit the appservice published room list"
)
await self.directory_handler.edit_published_appservice_room_list(
requester.app_service.id, network_id, room_id, visibility
requester.app_service_id, network_id, room_id, visibility
)
return 200, {}
+1 -1
View File
@@ -535,7 +535,7 @@ class SigningKeyUploadServlet(RestServlet):
# setup, and that is allowed without UIA, per MSC3967.
# If yes, then we need to authenticate the change.
# MSC4190 can skip UIA for replacing cross-signing keys as well.
if is_cross_signing_setup and not requester.app_service:
if is_cross_signing_setup and not requester.app_service_id:
# With MSC3861, UIA is not possible. Instead, the auth service has to
# explicitly mark the master key as replaceable.
if self.hs.config.mas.enabled:
+5 -1
View File
@@ -203,7 +203,11 @@ class LoginRestServlet(RestServlet):
try:
if login_submission["type"] == LoginRestServlet.APPSERVICE_TYPE:
requester = await self.auth.get_user_by_req(request)
appservice = requester.app_service
appservice = (
self._main_store.get_app_service_by_id(requester.app_service_id)
if requester.app_service_id
else None
)
if appservice is None:
raise InvalidClientTokenError(
+2 -2
View File
@@ -336,7 +336,7 @@ class RoomStateEventRestServlet(RestServlet):
)
origin_server_ts = None
if requester.app_service:
if requester.app_service_id:
origin_server_ts = parse_integer(request, "ts")
sticky_duration_ms: int | None = None
@@ -435,7 +435,7 @@ class RoomSendEventRestServlet(TransactionRestServlet):
content = parse_json_object_from_request(request)
origin_server_ts = None
if requester.app_service:
if requester.app_service_id:
origin_server_ts = parse_integer(request, "ts")
sticky_duration_ms: int | None = None
+2 -2
View File
@@ -82,8 +82,8 @@ class HttpTransactionCache:
assert requester.user is not None, "Guest requester must have a user ID set"
return (path, "guest", requester.user)
elif requester.app_service is not None:
return (path, "appservice", requester.app_service.id)
elif requester.app_service_id is not None:
return (path, "appservice", requester.app_service_id)
# Use the user ID and device ID as the transaction key.
elif requester.device_id:
+7 -1
View File
@@ -43,6 +43,7 @@ class CreateResource(RestServlet):
super().__init__()
self.media_repo = media_repo
self.store = hs.get_datastores().main
self.clock = hs.get_clock()
self.auth = hs.get_auth()
self.max_pending_media_uploads = hs.config.media.max_pending_media_uploads
@@ -60,7 +61,12 @@ class CreateResource(RestServlet):
# If the create media requests for the user are over the limit, drop them.
await self._create_media_rate_limiter.ratelimit(requester)
if not requester.app_service or requester.app_service.is_rate_limited():
app_service = (
self.store.get_app_service_by_id(requester.app_service_id)
if requester.app_service_id
else None
)
if not app_service or app_service.is_rate_limited():
(
reached_pending_limit,
first_expiration_ts,
+2 -1
View File
@@ -828,7 +828,8 @@ class MultiWriterIdGenerator(AbstractStreamIdGenerator):
# do.
break
# Hacky debug logging to attempt to trace https://github.com/element-hq/synapse/issues/19795
# Hacky debug logging to attempt to trace https://github.com/element-hq/synapse/issues/19795.
# If this is the to-device stream, and we are a writer for that stream, log some stats
if (
issue9533_logger.isEnabledFor(logging.DEBUG)
# Only log if we are the instance that is doing the persisting
+68
View File
@@ -0,0 +1,68 @@
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2026 Element Creations Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
from synapse.types import JsonDict, UserID
class Requester:
def __init__(
self,
user: UserID,
access_token_id: int | None,
is_guest: bool,
scope: set[str],
shadow_banned: bool,
device_id: str | None,
app_service_id: str | None,
authenticated_entity: str,
) -> None: ...
@property
def user_id(self) -> str:
"""The ID of the user making the request, in string form (see `user`
for the parsed UserID)"""
@property
def user(self) -> UserID:
"""The user making the request"""
@property
def access_token_id(self) -> int | None:
"""The ID of the access token used for this request, or
None for appservices, guests, and tokens generated by the admin API"""
@property
def is_guest(self) -> bool:
"""True if the user making this request is a guest user"""
@property
def scope(self) -> set[str]:
"""Any scopes associated with the access token used for this request, or
an empty set if no token or a non-oauth token was used"""
@property
def shadow_banned(self) -> bool:
"""True if the user making this request has been shadow-banned."""
@property
def device_id(self) -> str | None:
"""The device_id which was set at authentication time, or
None for appservices, guests, and tokens generated by the admin API"""
@property
def app_service_id(self) -> str | None:
"""The ID of the AS requesting on behalf of the user, or None."""
@property
def authenticated_entity(self) -> str:
"""The entity that authenticated when making the request.
This is different to the user_id when an admin user or the server is
"puppeting" the user."""
def serialize(self) -> JsonDict:
"""Converts self to a type that can be serialized as JSON, and then
deserialized by `deserialize`"""
@staticmethod
def deserialize(input: JsonDict) -> Requester:
"""Converts a dict that was produced by `serialize` back into a
Requester."""
+2 -78
View File
@@ -61,6 +61,7 @@ from twisted.internet.interfaces import (
)
from synapse.api.errors import Codes, SynapseError
from synapse.synapse_rust.types import Requester
from synapse.util.cancellation import cancellable
from synapse.util.stringutils import parse_and_validate_server_name
@@ -70,7 +71,6 @@ if TYPE_CHECKING:
from synapse.appservice.api import ApplicationService
from synapse.events import EventBase
from synapse.storage.databases.main import DataStore, PurgeEventsStore
from synapse.storage.databases.main.appservice import ApplicationServiceWorkerStore
from synapse.storage.util.id_generators import MultiWriterIdGenerator
@@ -138,82 +138,6 @@ class ISynapseReactor(
"""The interfaces necessary for Synapse to function."""
@attr.s(frozen=True, slots=True, auto_attribs=True)
class Requester:
"""
Represents the user making a request
Attributes:
user: id of the user making the request
access_token_id: *ID* of the access token used for this request, or
None for appservices, guests, and tokens generated by the admin API
is_guest: True if the user making this request is a guest user
shadow_banned: True if the user making this request has been shadow-banned.
device_id: device_id which was set at authentication time, or
None for appservices, guests, and tokens generated by the admin API
app_service: the AS requesting on behalf of the user
authenticated_entity: The entity that authenticated when making the request.
This is different to the user_id when an admin user or the server is
"puppeting" the user.
"""
user: "UserID"
access_token_id: int | None
is_guest: bool
scope: set[str]
shadow_banned: bool
device_id: str | None
app_service: Optional["ApplicationService"]
authenticated_entity: str
def serialize(self) -> dict[str, Any]:
"""Converts self to a type that can be serialized as JSON, and then
deserialized by `deserialize`
Returns:
dict
"""
return {
"user_id": self.user.to_string(),
"access_token_id": self.access_token_id,
"is_guest": self.is_guest,
"scope": list(self.scope),
"shadow_banned": self.shadow_banned,
"device_id": self.device_id,
"app_server_id": self.app_service.id if self.app_service else None,
"authenticated_entity": self.authenticated_entity,
}
@staticmethod
def deserialize(
store: "ApplicationServiceWorkerStore", input: dict[str, Any]
) -> "Requester":
"""Converts a dict that was produced by `serialize` back into a
Requester.
Args:
store: Used to convert AS ID to AS object
input: A dict produced by `serialize`
Returns:
Requester
"""
appservice = None
if input["app_server_id"]:
appservice = store.get_app_service_by_id(input["app_server_id"])
return Requester(
user=UserID.from_string(input["user_id"]),
access_token_id=input["access_token_id"],
is_guest=input["is_guest"],
scope=set(input.get("scope", [])),
shadow_banned=input["shadow_banned"],
device_id=input["device_id"],
app_service=appservice,
authenticated_entity=input["authenticated_entity"],
)
def create_requester(
user_id: Union[str, "UserID"],
access_token_id: int | None = None,
@@ -258,7 +182,7 @@ def create_requester(
scope,
shadow_banned,
device_id,
app_service,
app_service.id if app_service else None,
authenticated_entity,
)
+48 -14
View File
@@ -203,6 +203,9 @@ class SlidingSyncResult:
highlight_count: int
def __bool__(self) -> bool:
"""Are there any updates that should be returned immediately to
the client?
"""
return (
# If this is the first time the client is seeing the room, we should not filter it out
# under any circumstance.
@@ -270,6 +273,8 @@ class SlidingSyncResult:
events: Sequence[JsonMapping]
def __bool__(self) -> bool:
"""Are there any updates that should be returned immediately to
the client?"""
return bool(self.events)
@attr.s(slots=True, frozen=True, auto_attribs=True)
@@ -294,23 +299,37 @@ class SlidingSyncResult:
device_unused_fallback_key_types: Sequence[str]
def __bool__(self) -> bool:
# Note that "signed_curve25519" is always returned in key count responses
# regardless of whether we uploaded any keys for it. This is necessary until
"""Are there any updates that should be returned immediately to
the client?"""
# Note that "signed_curve25519" is always returned in key count
# responses regardless of whether we uploaded any keys for it.
# This is necessary until
# https://github.com/matrix-org/matrix-doc/issues/3298 is fixed.
#
# Also related:
# https://github.com/element-hq/element-android/issues/3725 and
# https://github.com/matrix-org/synapse/issues/10456
default_otk = self.device_one_time_keys_count.get("signed_curve25519")
more_than_default_otk = len(self.device_one_time_keys_count) > 1 or (
default_otk is not None and default_otk > 0
)
#
# This is why we don't incorporate `device_one_time_keys_count`
# (or `device_unused_fallback_key_types`) into the `__bool__`
# check.
#
# FIXME: Ideally we'd detect if either of those fields have
# changed since the last sync, but we do not currently track
# such state.
#
# Note that the client will receive these fields eventually when
# we respond to the sync request (usually sync timeouts are set
# to ~30s), we just won't immediately respond (even if there are
# changes). This delay is acceptable for clients, as a) these
# fields do not trigger UI (and so don't affect user perceivable
# latency) and b) are handled in the background by the clients
# anyway. The only risk being that one-time keys could be exhausted
# before the client knows about adding some more. But for example,
# if the client is syncing with a timeout of 30s, the window of
# staleness is so small for this not to matter.
return bool(
more_than_default_otk
or self.device_list_updates
or self.device_unused_fallback_key_types
)
return bool(self.device_list_updates)
@attr.s(slots=True, frozen=True, auto_attribs=True)
class AccountDataExtension:
@@ -327,6 +346,8 @@ class SlidingSyncResult:
account_data_by_room_map: Mapping[str, Mapping[str, JsonMapping]]
def __bool__(self) -> bool:
"""Are there any updates that should be returned immediately to
the client?"""
return bool(
self.global_account_data_map or self.account_data_by_room_map
)
@@ -343,6 +364,8 @@ class SlidingSyncResult:
room_id_to_receipt_map: Mapping[str, JsonMapping]
def __bool__(self) -> bool:
"""Are there any updates that should be returned immediately to
the client?"""
return bool(self.room_id_to_receipt_map)
@attr.s(slots=True, frozen=True, auto_attribs=True)
@@ -357,6 +380,8 @@ class SlidingSyncResult:
room_id_to_typing_map: Mapping[str, JsonMapping]
def __bool__(self) -> bool:
"""Are there any updates that should be returned immediately to
the client?"""
return bool(self.room_id_to_typing_map)
@attr.s(slots=True, frozen=True, auto_attribs=True)
@@ -391,6 +416,8 @@ class SlidingSyncResult:
prev_batch: ThreadSubscriptionsToken | None
def __bool__(self) -> bool:
"""Are there any updates that should be returned immediately to
the client?"""
return (
bool(self.subscribed)
or bool(self.unsubscribed)
@@ -405,6 +432,8 @@ class SlidingSyncResult:
thread_subscriptions: ThreadSubscriptionsExtension | None = None
def __bool__(self) -> bool:
"""Are there any updates that should be returned immediately to
the client?"""
return bool(
self.to_device
or self.e2ee
@@ -420,9 +449,14 @@ class SlidingSyncResult:
extensions: Extensions
def __bool__(self) -> bool:
"""Make the result appear empty if there are no updates. This is used
to tell if the notifier needs to wait for more events when polling for
events.
"""Are there any updates that should be returned immediately to
the client?
This is used to determine if a sliding sync response should be returned
immediately or if the notifier needs to wait for further updates, and
thus MUST return false if there is no new data since the last sync. This
is subtly different than just checking if any of the fields are set,
since some fields are always included (like `bump_stamp`).
"""
# We don't include `self.lists` here, as a) `lists` is always non-empty even if
# there are no changes, and b) since we're sorting rooms by `stream_ordering` of
+10 -2
View File
@@ -108,6 +108,7 @@ class AuthTestCase(unittest.HomeserverTestCase):
def test_get_user_by_req_appservice_valid_token(self) -> None:
app_service = Mock(
id="as_id",
token="foobar",
url="a_url",
sender=self.test_user_id,
@@ -132,6 +133,7 @@ class AuthTestCase(unittest.HomeserverTestCase):
sender=self.test_user_id.to_string(),
ip_range_whitelist=IPSet(["192.168.0.0/16"]),
)
app_service.id = "as_id"
self.store.get_app_service_by_token = Mock(return_value=app_service)
self.store.get_user_by_access_token = AsyncMock(return_value=None)
@@ -151,6 +153,7 @@ class AuthTestCase(unittest.HomeserverTestCase):
sender=self.test_user_id,
ip_range_whitelist=IPSet(["192.168.0.0/16"]),
)
app_service.id = "as_id"
self.store.get_app_service_by_token = Mock(return_value=app_service)
self.store.get_user_by_access_token = AsyncMock(return_value=None)
@@ -179,6 +182,7 @@ class AuthTestCase(unittest.HomeserverTestCase):
def test_get_user_by_req_appservice_missing_token(self) -> None:
app_service = Mock(token="foobar", url="a_url", sender=self.test_user_id)
app_service.id = "as_id"
self.store.get_app_service_by_token = Mock(return_value=app_service)
self.store.get_user_by_access_token = AsyncMock(return_value=None)
@@ -199,6 +203,7 @@ class AuthTestCase(unittest.HomeserverTestCase):
ip_range_whitelist=None,
)
app_service.is_interested_in_user = Mock(return_value=True)
app_service.id = "as_id"
self.store.get_app_service_by_token = Mock(return_value=app_service)
class FakeUserInfo:
@@ -226,6 +231,7 @@ class AuthTestCase(unittest.HomeserverTestCase):
ip_range_whitelist=None,
)
app_service.is_interested_in_user = Mock(return_value=False)
app_service.id = "as_id"
self.store.get_app_service_by_token = Mock(return_value=app_service)
self.store.get_user_by_access_token = AsyncMock(return_value=None)
@@ -251,6 +257,7 @@ class AuthTestCase(unittest.HomeserverTestCase):
ip_range_whitelist=None,
)
app_service.is_interested_in_user = Mock(return_value=True)
app_service.id = "as_id"
self.store.get_app_service_by_token = Mock(return_value=app_service)
# This just needs to return a truth-y value.
self.store.get_user_by_id = AsyncMock(return_value={"is_guest": False})
@@ -285,6 +292,7 @@ class AuthTestCase(unittest.HomeserverTestCase):
ip_range_whitelist=None,
)
app_service.is_interested_in_user = Mock(return_value=True)
app_service.id = "as_id"
self.store.get_app_service_by_token = Mock(return_value=app_service)
# This just needs to return a truth-y value.
self.store.get_user_by_id = AsyncMock(return_value={"is_guest": False})
@@ -457,7 +465,7 @@ class AuthTestCase(unittest.HomeserverTestCase):
is_guest=False,
scope=set(),
shadow_banned=False,
app_service=appservice,
app_service_id=appservice.id,
authenticated_entity="@appservice:server",
)
self.get_success(self.auth_blocking.check_auth_blocking(requester=requester))
@@ -488,7 +496,7 @@ class AuthTestCase(unittest.HomeserverTestCase):
is_guest=False,
scope=set(),
shadow_banned=False,
app_service=appservice,
app_service_id=appservice.id,
authenticated_entity="@appservice:server",
)
self.get_failure(
+6
View File
@@ -40,6 +40,9 @@ class TestRatelimiter(unittest.HomeserverTestCase):
rate_limited=True,
sender=UserID.from_string("@as:example.com"),
)
# The ratelimiter now resolves the AS via get_app_service_by_id, so the
# appservice must be in the store's cache for the lookup to hit.
self.hs.get_datastores().main.services_cache.append(appservice)
as_requester = create_requester("@user:example.com", app_service=appservice)
limiter = Ratelimiter(
@@ -76,6 +79,9 @@ class TestRatelimiter(unittest.HomeserverTestCase):
rate_limited=False,
sender=UserID.from_string("@as:example.com"),
)
# The ratelimiter now resolves the AS via get_app_service_by_id, so the
# appservice must be in the store's cache for the lookup to hit.
self.hs.get_datastores().main.services_cache.append(appservice)
as_requester = create_requester("@user:example.com", app_service=appservice)
limiter = Ratelimiter(
@@ -290,6 +290,173 @@ class SlidingSyncE2eeExtensionTestCase(SlidingSyncBase):
[],
)
def test_wait_for_new_data_timeout_with_otks(self) -> None:
"""
Test that an incremental Sliding Sync with the e2ee extension enabled
does not return immediately when the user has uploaded one-time keys
(i.e. `device_one_time_keys_count` contains entries beyond the default
`signed_curve25519: 0`).
"""
test_device_id = "TESTDEVICE"
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass", device_id=test_device_id)
# Upload one-time keys for the user/device so that
# `device_one_time_keys_count` is non-default in the response.
self.get_success(
self.e2e_keys_handler.upload_keys_for_user(
user1_id,
test_device_id,
{
"one_time_keys": {
"alg1:k1": "key1",
"alg2:k2": {"key": "key2", "signatures": {"k1": "sig1"}},
}
},
)
)
sync_body = {
"lists": {},
"extensions": {
"e2ee": {
"enabled": True,
}
},
}
_, from_token = self.do_sync(sync_body, tok=user1_tok)
# Make an incremental Sliding Sync request with a timeout
channel = self.make_request(
"POST",
self.sync_endpoint + f"?timeout=10000&pos={from_token}",
content=sync_body,
access_token=user1_tok,
await_result=False,
)
# Block for 5 seconds to make sure we are `notifier.wait_for_events(...)`
with self.assertRaises(TimedOutException):
channel.await_result(timeout_ms=5000)
# Wake-up `notifier.wait_for_events(...)` that will cause us to test
# `SlidingSyncResult.__bool__` for new results. The non-default
# `device_one_time_keys_count` must not be considered new data.
self._bump_notifier_wait_for_events(
user1_id, wake_stream_key=StreamKeyType.ACCOUNT_DATA
)
# Block for a little bit more to ensure we don't see any new results.
with self.assertRaises(TimedOutException):
channel.await_result(timeout_ms=4000)
# Wait for the sync to complete (wait for the rest of the 10 second timeout,
# 5000 + 4000 + 1200 > 10000)
channel.await_result(timeout_ms=1200)
self.assertEqual(channel.code, 200, channel.json_body)
# Device lists are present for incremental syncs but empty because no device changes
self.assertEqual(
channel.json_body["extensions"]["e2ee"]
.get("device_lists", {})
.get("changed"),
[],
)
self.assertEqual(
channel.json_body["extensions"]["e2ee"].get("device_lists", {}).get("left"),
[],
)
# The one-time key counts are present, but they should not have caused
# the sync to return early.
self.assertEqual(
channel.json_body["extensions"]["e2ee"]["device_one_time_keys_count"],
{
"alg1": 1,
"alg2": 1,
"signed_curve25519": 0,
},
)
self.assertEqual(
channel.json_body["extensions"]["e2ee"]["device_unused_fallback_key_types"],
[],
)
def test_wait_for_new_data_timeout_with_fallback_keys(self) -> None:
"""
Test that an incremental Sliding Sync with the e2ee extension enabled
does not return immediately when the user has uploaded fallback keys
(i.e. `device_unused_fallback_key_types` is non-empty).
"""
test_device_id = "TESTDEVICE"
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass", device_id=test_device_id)
# Upload a fallback key for the user/device so that
# `device_unused_fallback_key_types` is non-empty in the response.
self.get_success(
self.e2e_keys_handler.upload_keys_for_user(
user1_id,
test_device_id,
{"fallback_keys": {"alg1:k1": "fallback_key1"}},
)
)
sync_body = {
"lists": {},
"extensions": {
"e2ee": {
"enabled": True,
}
},
}
_, from_token = self.do_sync(sync_body, tok=user1_tok)
# Make an incremental Sliding Sync request with a timeout
channel = self.make_request(
"POST",
self.sync_endpoint + f"?timeout=10000&pos={from_token}",
content=sync_body,
access_token=user1_tok,
await_result=False,
)
# Block for 5 seconds to make sure we are `notifier.wait_for_events(...)`
with self.assertRaises(TimedOutException):
channel.await_result(timeout_ms=5000)
# Wake-up `notifier.wait_for_events(...)` that will cause us to test
# `SlidingSyncResult.__bool__` for new results. The non-empty
# `device_unused_fallback_key_types` must not be considered new data.
self._bump_notifier_wait_for_events(
user1_id, wake_stream_key=StreamKeyType.ACCOUNT_DATA
)
# Block for a little bit more to ensure we don't see any new results.
with self.assertRaises(TimedOutException):
channel.await_result(timeout_ms=4000)
# Wait for the sync to complete (wait for the rest of the 10 second timeout,
# 5000 + 4000 + 1200 > 10000)
channel.await_result(timeout_ms=1200)
self.assertEqual(channel.code, 200, channel.json_body)
# Device lists are present for incremental syncs but empty because no device changes
self.assertEqual(
channel.json_body["extensions"]["e2ee"]
.get("device_lists", {})
.get("changed"),
[],
)
self.assertEqual(
channel.json_body["extensions"]["e2ee"].get("device_lists", {}).get("left"),
[],
)
# The unused fallback key types are present, but they should not have
# caused the sync to return early.
self.assertEqual(
channel.json_body["extensions"]["e2ee"]["device_unused_fallback_key_types"],
["alg1"],
)
def test_device_lists(self) -> None:
"""
Test that device list updates are included in the response
@@ -22,6 +22,7 @@ import synapse.rest.admin
from synapse.api.constants import EventTypes, HistoryVisibility
from synapse.rest.client import login, room, sync
from synapse.server import HomeServer
from synapse.types import JsonDict
from synapse.util.clock import Clock
from tests.rest.client.sliding_sync.test_sliding_sync import SlidingSyncBase
@@ -126,6 +127,124 @@ class SlidingSyncRoomSubscriptionsTestCase(SlidingSyncBase):
response_body["rooms"][room_id1],
)
def test_room_subscription_required_state_expansion_returns_immediately(
self,
) -> None:
"""
Test that adding a room subscription with stronger params than the list causes an
incremental long-poll to return immediately, even without new stream activity.
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
room_id1 = self.helper.create_room_as(user1_id, tok=user1_tok)
sync_body: JsonDict = {
"lists": {
"foo-list": {
"ranges": [[0, 0]],
"required_state": [],
"timeline_limit": 0,
}
},
"conn_id": "conn_id",
}
_, from_token = self.do_sync(sync_body, tok=user1_tok)
sync_body["room_subscriptions"] = {
room_id1: {
"required_state": [
[EventTypes.Create, ""],
],
"timeline_limit": 0,
}
}
channel = self.make_request(
"POST",
self.sync_endpoint + f"?timeout=10000&pos={from_token}",
content=sync_body,
access_token=user1_tok,
await_result=False,
)
channel.await_result(timeout_ms=3000)
self.assertEqual(channel.code, 200, channel.json_body)
state_map = self.get_success(
self.storage_controllers.state.get_current_state(room_id1)
)
room_response = channel.json_body["rooms"][room_id1]
self.assertNotIn("initial", room_response)
self._assertRequiredStateIncludes(
room_response["required_state"],
{
state_map[(EventTypes.Create, "")],
},
exact=True,
)
def test_room_subscription_required_state_change_returns_immediately(self) -> None:
"""
Test that expanding an existing room subscription's required state causes an
incremental long-poll to return immediately, even without new stream activity.
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
room_id1 = self.helper.create_room_as(
user1_id, tok=user1_tok, extra_content={"name": "Foo"}
)
sync_body: JsonDict = {
"room_subscriptions": {
room_id1: {
"required_state": [
[EventTypes.Create, ""],
],
"timeline_limit": 0,
}
},
"conn_id": "conn_id",
}
response_body, from_token = self.do_sync(sync_body, tok=user1_tok)
state_map = self.get_success(
self.storage_controllers.state.get_current_state(room_id1)
)
self._assertRequiredStateIncludes(
response_body["rooms"][room_id1]["required_state"],
{
state_map[(EventTypes.Create, "")],
},
exact=True,
)
sync_body["room_subscriptions"][room_id1]["required_state"] = [
[EventTypes.Create, ""],
[EventTypes.Name, ""],
]
channel = self.make_request(
"POST",
self.sync_endpoint + f"?timeout=10000&pos={from_token}",
content=sync_body,
access_token=user1_tok,
await_result=False,
)
channel.await_result(timeout_ms=3000)
self.assertEqual(channel.code, 200, channel.json_body)
room_response = channel.json_body["rooms"][room_id1]
self.assertNotIn("initial", room_response)
self._assertRequiredStateIncludes(
room_response["required_state"],
{
state_map[(EventTypes.Name, "")],
},
exact=True,
)
def test_room_subscriptions_with_leave_membership(self) -> None:
"""
Test `room_subscriptions` with a leave room should give us timeline and state
@@ -25,8 +25,10 @@ from synapse.rest.client import knock, login, room, sync
from synapse.server import HomeServer
from synapse.storage.databases.main.events import DeltaState, SlidingSyncTableChanges
from synapse.util.clock import Clock
from synapse.util.duration import Duration
from tests.rest.client.sliding_sync.test_sliding_sync import SlidingSyncBase
from tests.server import TimedOutException
from tests.test_utils.event_injection import mark_event_as_partial_state
logger = logging.getLogger(__name__)
@@ -1924,7 +1926,12 @@ class SlidingSyncRoomsRequiredStateTestCase(SlidingSyncBase):
def test_rooms_required_state_expand_retract_expand(self) -> None:
"""Test that when expanding, retracting and then expanding the required
state, we get the changes that happened."""
state, we get the changes that happened.
Also see `test_changing_required_state_returns_immediately`, which tests
that the sync stream is woken up immediately when changing the required
state, and not just on the next change to the room.
"""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
@@ -2245,3 +2252,75 @@ class SlidingSyncRoomsRequiredStateTestCase(SlidingSyncBase):
response_body["rooms"][room_id]["required_state"][0]["event_id"],
first_event_id,
)
def test_changing_required_state_returns_immediately(self) -> None:
"""Test that if we change the `required_state`, then we return immediately
with the new `required_state`."""
user1_id = self.register_user("user1", "pass")
user1_tok = self.login(user1_id, "pass")
room_id1 = self.helper.create_room_as(user1_id, tok=user1_tok)
# Make an initial sync request with no required state
sync_body = {
"lists": {
"foo-list": {
"ranges": [[0, 1]],
"required_state": [],
"timeline_limit": 0,
}
}
}
response_body, from_token = self.do_sync(sync_body, tok=user1_tok)
# We should see no required state
self.assertIsNone(response_body["rooms"][room_id1].get("required_state"))
# Get the state_map before we change the state as this is the final state we
# expect to see when we update the required state.
state_map = self.get_success(
self.storage_controllers.state.get_current_state(room_id1)
)
# There is no new data, and so making another sync request will block.
channel = self.make_sync_request(
sync_body,
since=from_token,
tok=user1_tok,
timeout=Duration(seconds=10),
await_result=False,
)
# Request will block for 10 seconds as there no updates.
with self.assertRaises(TimedOutException):
channel.await_result(timeout_ms=9500)
# Wait for the request to actually finish. (We do this to ensure log
# contexts don't leak between tests).
channel.await_result(timeout_ms=1000)
# Now update the Sliding Sync requests to include a `required_state`
# event, and make another sync request.
sync_body["lists"]["foo-list"]["required_state"] = [
[EventTypes.Create, ""],
]
channel = self.make_sync_request(
sync_body,
since=from_token,
tok=user1_tok,
timeout=Duration(seconds=10),
await_result=False,
)
# We should see the new `required_state` immediately without waiting
channel.await_result(timeout_ms=0)
response_body = channel.json_body
self._assertRequiredStateIncludes(
response_body["rooms"][room_id1]["required_state"],
{
state_map[(EventTypes.Create, "")],
},
exact=True,
)
@@ -12,6 +12,7 @@
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
import logging
import urllib.parse
from typing import Any, Iterable, Literal
from unittest.mock import AsyncMock
@@ -43,6 +44,7 @@ from synapse.types import (
StreamToken,
)
from synapse.util.clock import Clock
from synapse.util.duration import Duration
from synapse.util.stringutils import random_string
from tests import unittest
@@ -82,7 +84,13 @@ class SlidingSyncBase(unittest.HomeserverTestCase):
return config
def make_sync_request(
self, sync_body: JsonDict, *, since: str | None = None, tok: str
self,
sync_body: JsonDict,
*,
since: str | None = None,
tok: str,
timeout: Duration | None = None,
await_result: bool = True,
) -> FakeChannel:
"""Make a sliding sync request with given body.
@@ -90,25 +98,40 @@ class SlidingSyncBase(unittest.HomeserverTestCase):
sync_body: The full request body to use
since: Optional since token
tok: Access token to use
timeout_ms: Optional timeout in milliseconds to use for the request.
await_result: Whether to block and wait for the result before returning.
Returns:
A tuple of the response body and the `pos` field.
"""
sync_path = self.sync_endpoint
query_params: dict[str, Any] = {}
if since:
sync_path += f"?pos={since}"
query_params["pos"] = since
if timeout is not None:
query_params["timeout"] = timeout.as_millis()
if query_params:
query_str = urllib.parse.urlencode(query_params)
sync_path += f"?{query_str}"
channel = self.make_request(
method="POST",
path=sync_path,
content=sync_body,
access_token=tok,
await_result=await_result,
)
return channel
def do_sync(
self, sync_body: JsonDict, *, since: str | None = None, tok: str
self,
sync_body: JsonDict,
*,
since: str | None = None,
tok: str,
timeout: Duration | None = None,
) -> tuple[JsonDict, str]:
"""Do a sliding sync request with given body.
@@ -118,11 +141,14 @@ class SlidingSyncBase(unittest.HomeserverTestCase):
sync_body: The full request body to use
since: Optional since token
tok: Access token to use
timeout: Optional timeout to use for the request.
Returns:
A tuple of the response body and the `pos` field.
"""
channel = self.make_sync_request(sync_body, since=since, tok=tok)
channel = self.make_sync_request(
sync_body, since=since, tok=tok, timeout=timeout
)
self.assertEqual(channel.code, 200, channel.json_body)
return channel.json_body, channel.json_body["pos"]
+1 -1
View File
@@ -52,7 +52,7 @@ class HttpTransactionCacheTestCase(unittest.TestCase):
self.mock_request = Mock()
self.mock_request.path = b"/foo/bar"
self.mock_requester = Mock()
self.mock_requester.app_service = None
self.mock_requester.app_service_id = None
self.mock_requester.is_guest = False
self.mock_requester.access_token_id = 1234