diff --git a/rust/src/events/local.rs b/rust/src/events/local.rs index 4544eb7dce..b908ee9495 100644 --- a/rust/src/events/local.rs +++ b/rust/src/events/local.rs @@ -19,7 +19,10 @@ //! redacted event with the server's key, and parse the result as an //! [`Event`]. -use std::sync::Arc; +use std::{ + collections::{BTreeMap, HashMap}, + sync::Arc, +}; use anyhow::{bail, Context}; use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine}; @@ -38,7 +41,8 @@ use super::{ Event, }; use crate::{ - canonical_json::{to_vec_canonical, CanonicalizationOptions}, + canonical_json::{to_vec_canonical, CanonicalizationOptions, MAX_VALID_INTEGER}, + event_auth::auth_types_for, federation::signing::FederationSigningKey, room_versions::{EventFormatVersions, RoomVersion}, }; @@ -73,29 +77,174 @@ impl LocalEventSigner { room_version: &Bound<'_, PyAny>, internal_metadata_dict: &Bound<'_, PyDict>, ) -> PyResult { - let room_version: &'static RoomVersion = room_version - .getattr("identifier")? - .extract::<&str>()? - .parse() - .map_err(|e| PyValueError::new_err(format!("Unsupported room version: {e}")))?; + let room_version = room_version_from_py(room_version)?; let internal_metadata = EventInternalMetadata::new(internal_metadata_dict)?; - self.sign(event_json, room_version, internal_metadata) + let event: Map = serde_json::from_slice(event_json) + .context("Failed to parse event JSON") + .map_err(|err| PyValueError::new_err(format!("{err:#}")))?; + + self.sign(event, room_version, internal_metadata) .map_err(|err| PyValueError::new_err(format!("{err:#}"))) } + + /// Build the state events that follow the create event of a new room, as + /// the room handler's batch builds them one by one: each event's prev + /// event is the one before it, its depth one more, and its auth events + /// are picked from the state before it, which starts as `state_map`. + /// + /// `specs` are `(type, state_key, content)`, with the content as its + /// canonical JSON. Every event gets `origin_server_ts` and a fresh copy + /// of `internal_metadata_dict`. + /// + /// Only for room versions whose events refer to other events by ID + /// (not v1 and v2) and that have no state DAG (MSC4242). Raises + /// `ValueError` for anything it cannot build, and the caller builds the + /// events in Python instead. + #[pyo3(signature = ( + room_version, room_id, sender, prev_event_id, depth, state_map, specs, + origin_server_ts, internal_metadata_dict, + ))] + #[allow(clippy::too_many_arguments)] + fn build_room_events( + &self, + room_version: &Bound<'_, PyAny>, + room_id: &str, + sender: &str, + prev_event_id: String, + depth: i64, + state_map: HashMap<(String, String), String>, + specs: Vec<(String, String, Vec)>, + origin_server_ts: i64, + internal_metadata_dict: &Bound<'_, PyDict>, + ) -> PyResult> { + let room_version = room_version_from_py(room_version)?; + let to_py = |err: anyhow::Error| PyValueError::new_err(format!("{err:#}")); + + if room_version.event_format == EventFormatVersions::ROOM_V1_V2 + || room_version.msc4242_state_dags + { + return Err(PyValueError::new_err(format!( + "Cannot build a room batch for room version {room_version}" + ))); + } + + let mut state_map = state_map; + let mut prev_event_id = prev_event_id; + let mut events = Vec::with_capacity(specs.len()); + + for (depth, (event_type, state_key, content_json)) in (depth..).zip(specs) { + let internal_metadata = EventInternalMetadata::new(internal_metadata_dict)?; + let content: Map = serde_json::from_slice(&content_json) + .context("Failed to parse event content") + .map_err(to_py)?; + + let auth_event_ids = room_batch_auth_event_ids( + room_version, + room_id, + &event_type, + sender, + &state_key, + &content, + &state_map, + ) + .map_err(to_py)?; + + let mut unsigned = Map::new(); + unsigned.insert(AGE_TS.to_owned(), Value::from(origin_server_ts)); + + let mut event = Map::new(); + event.insert("auth_events".to_owned(), Value::from(auth_event_ids)); + event.insert( + "prev_events".to_owned(), + Value::from(vec![prev_event_id.clone()]), + ); + event.insert("type".to_owned(), Value::from(event_type.clone())); + event.insert("sender".to_owned(), Value::from(sender)); + event.insert("content".to_owned(), Value::Object(content)); + event.insert(UNSIGNED.to_owned(), Value::Object(unsigned)); + // Python caps the depth of new events, so other servers accept + // them. + event.insert( + "depth".to_owned(), + Value::from(depth.min(MAX_VALID_INTEGER)), + ); + event.insert("room_id".to_owned(), Value::from(room_id)); + event.insert("state_key".to_owned(), Value::from(state_key.clone())); + event.insert("origin_server_ts".to_owned(), Value::from(origin_server_ts)); + event.insert(SIGNATURES.to_owned(), Value::Object(Map::new())); + + let event = self + .sign(event, room_version, internal_metadata) + .map_err(to_py)?; + + prev_event_id = event.event_id().to_owned(); + state_map.insert((event_type, state_key), prev_event_id.clone()); + events.push(event); + } + + Ok(events) + } +} + +/// The room version Python passes, by its identifier. +fn room_version_from_py(room_version: &Bound<'_, PyAny>) -> PyResult<&'static RoomVersion> { + room_version + .getattr("identifier")? + .extract::<&str>()? + .parse() + .map_err(|e| PyValueError::new_err(format!("Unsupported room version: {e}"))) +} + +/// The auth events of a new state event, as `compute_auth_events` and the +/// builder pick them: the events in `state_map` for the auth types of the +/// event, in that order. In MSC4291 rooms the create event is implied by the +/// room ID, so it is left out. +fn room_batch_auth_event_ids( + room_version: &RoomVersion, + room_id: &str, + event_type: &str, + sender: &str, + state_key: &str, + content: &Map, + state_map: &HashMap<(String, String), String>, +) -> anyhow::Result> { + let content: BTreeMap, Value> = content + .iter() + .map(|(key, value)| (Box::from(key.as_str()), value.clone())) + .collect(); + let auth_types = auth_types_for(room_version, event_type, sender, Some(state_key), &content) + .map_err(|err| anyhow::anyhow!("{err}"))?; + + let mut auth_event_ids: Vec = auth_types + .into_iter() + .filter_map(|(auth_type, auth_state_key)| { + state_map + .get(&(auth_type.to_owned(), auth_state_key.to_owned())) + .filter(|event_id| !event_id.is_empty()) + .cloned() + }) + .collect(); + + if room_version.msc4291_room_ids_as_hashes { + let create_event_id = format!("${}", &room_id[1..]); + let Some(index) = auth_event_ids.iter().position(|id| *id == create_event_id) else { + bail!("the create event {create_event_id} is not in the state"); + }; + auth_event_ids.remove(index); + } + + Ok(auth_event_ids) } impl LocalEventSigner { fn sign( &self, - event_json: &[u8], + mut event: Map, room_version: &'static RoomVersion, internal_metadata: EventInternalMetadata, ) -> anyhow::Result { - let mut event: Map = - serde_json::from_slice(event_json).context("Failed to parse event JSON")?; - let reference_hash = add_hashes_and_signature( &mut event, room_version, diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py index e138aed4ea..470b3a5651 100644 --- a/synapse/config/experimental.py +++ b/synapse/config/experimental.py @@ -399,6 +399,24 @@ class ExperimentalConfig(Config): ) self.rust_sliding_sync: Literal["off", "on"] = rust_sliding_sync + # Whether a new room's events after its create event are built, hashed + # and signed in one Rust call, rather than one Python `create_event` + # each. Python still runs every check and persists them. Rooms Rust + # cannot build (v1 and v2, MSC4242 state DAGs, appservice senders, + # `check_event_allowed` modules, `m.room.aliases` in the initial + # state) are built in Python. + rust_room_creation = experimental.get("rust_room_creation", "off") + if rust_room_creation is True: + rust_room_creation = "on" + elif rust_room_creation is False: + rust_room_creation = "off" + if rust_room_creation not in ("off", "on"): + raise ConfigError( + "experimental_features.rust_room_creation must be 'off' or 'on'", + ("experimental", "rust_room_creation"), + ) + self.rust_room_creation: Literal["off", "on"] = rust_room_creation + # How much of outbound federation runs in Rust. One ordered level, each # implying the ones before it: # - "off": all Python. diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py index eccb6ea643..be4f415eb9 100644 --- a/synapse/handlers/room.py +++ b/synapse/handlers/room.py @@ -39,6 +39,8 @@ from typing import ( ) import attr +from canonicaljson import encode_canonical_json +from prometheus_client import Counter import synapse.events.snapshot from synapse.api.constants import ( @@ -79,8 +81,10 @@ from synapse.events.utils import ( copy_and_fixup_power_levels_contents, ) from synapse.handlers.relations import BundledAggregations +from synapse.metrics import SERVER_NAME_LABEL from synapse.rest.admin._base import assert_user_is_admin from synapse.streams import EventSource +from synapse.synapse_rust.events import EventInternalMetadata from synapse.types import ( JsonDict, JsonMapping, @@ -116,6 +120,23 @@ id_server_scheme = "https://" FIVE_MINUTES_IN_MS = 5 * 60 * 1000 +# Event types whose creation runs checks the Rust room builder leaves to +# `create_event`: `m.call.invite` against the database, and `m.room.create`, +# which cannot be sent again. (`m.room.aliases` in the initial state +# already has the create event and the join persisted on their own, which the +# Rust builder does not cover.) +_NOT_BUILT_IN_RUST = frozenset({EventTypes.CallInvite, EventTypes.Create}) + +# New rooms in `rust_room_creation: on` mode. `result` is "rust" for a room +# whose events after the create event Rust built, "fallback" for one Rust does +# not cover, or "error" for one Python built after Rust failed on it. +rust_room_creation_counter = Counter( + "synapse_rust_room_creation_total", + "Rooms created in `rust_room_creation: on` mode, by whether Rust built " + "their initial events", + labelnames=["result", SERVER_NAME_LABEL], +) + @attr.s(slots=True, frozen=True, auto_attribs=True) class EventContext: @@ -129,6 +150,10 @@ class EventContext: class RoomCreationHandler: + # Tests set this, so that a Rust failure fails the test rather than falling + # back to Python. + _rust_room_creation_strict = False + def __init__(self, hs: "HomeServer"): self.server_name = hs.hostname self.store = hs.get_datastores().main @@ -142,6 +167,8 @@ class RoomCreationHandler: self.room_member_handler = hs.get_room_member_handler() self._event_auth_handler = hs.get_event_auth_handler() self.config = hs.config + self._rust_room_creation = hs.config.experimental.rust_room_creation == "on" + self._event_signer = hs.get_event_builder_factory().signer self.common_request_ratelimiter = hs.get_request_ratelimiter() self.creation_ratelimiter = Ratelimiter( store=self.store, @@ -1702,6 +1729,90 @@ class RoomCreationHandler: ) assert not (persist_create_and_join_alone and batched_invites) + # In `rust_room_creation: on` mode, the events after the create event + # are collected here as `(type, state_key, content)`, then built in one + # Rust call by `build_collected_events`. None when each is built in + # Python as it comes. + collected: list[tuple[str, str, JsonMapping]] | None = None + if self._rust_room_creation: + if self._rust_room_creation_unsupported( + creator, room_version, persist_create_and_join_alone + ): + rust_room_creation_counter.labels( + result="fallback", **{SERVER_NAME_LABEL: self.hs.hostname} + ).inc() + else: + collected = [] + + async def add_event( + etype: str, content: JsonMapping, state_key: str = "" + ) -> None: + """Build the next event of the batch, or collect it for Rust.""" + if collected is not None: + collected.append((etype, state_key, content)) + return + event, context = await create_event( + etype, content, True, state_key=state_key + ) + events_to_send.append((event, context)) + + async def build_collected_events() -> None: + """Build the collected events and add them to the batch, in Rust, or + in Python if Rust cannot build them.""" + nonlocal collected, depth, prev_event + + specs, collected = collected, None + if not specs: + return + + if any(etype in _NOT_BUILT_IN_RUST for etype, _, _ in specs): + rust_room_creation_counter.labels( + result="fallback", **{SERVER_NAME_LABEL: self.hs.hostname} + ).inc() + for etype, state_key, content in specs: + await add_event(etype, content, state_key=state_key) + return + + try: + events = self._build_room_events_in_rust( + creator, room_version, room_id, prev_event, depth, state_map, specs + ) + except Exception as rust_error: + # Build them in Python. If Python rejects them too, the request + # was bad, and Python's error is the one to give. Otherwise Rust + # got it wrong. + for etype, state_key, content in specs: + await add_event(etype, content, state_key=state_key) + logger.error( + "Failed to build the events of room %s in Rust", + room_id, + exc_info=rust_error, + ) + rust_room_creation_counter.labels( + result="error", **{SERVER_NAME_LABEL: self.hs.hostname} + ).inc() + if self._rust_room_creation_strict: + raise + return + + rust_room_creation_counter.labels( + result="rust", **{SERVER_NAME_LABEL: self.hs.hostname} + ).inc() + + # The checks `create_event` runs on each event it builds. Those on + # the requester ran when it built the create event. + for event in events: + await self._check_event_built_in_rust(event) + context = await self.hs.get_state_handler().calculate_context_info( + event, + state_ids_before_event=dict(state_map), + partial_state=False, + ) + events_to_send.append((event, context)) + depth += 1 + prev_event = [event.event_id] + state_map[(event.type, event.state_key)] = event.event_id + if creation_event_with_context is None: # MSC2175 removes the creator field from the create event. if not room_version.implicit_room_creator: @@ -1729,10 +1840,7 @@ class RoomCreationHandler: join_content = await self.room_member_handler.build_creator_join_content( creator, creator_join_profile ) - join_event, join_context = await create_event( - EventTypes.Member, join_content, True, state_key=creator_id - ) - events_to_send.append((join_event, join_context)) + await add_event(EventTypes.Member, join_content, state_key=creator_id) if persist_create_and_join_alone: await persist_batch() @@ -1741,10 +1849,7 @@ class RoomCreationHandler: # If the `initial_state` has `EventTypes.PowerLevels` content, use it. pl_content = initial_state.pop((EventTypes.PowerLevels, ""), None) if pl_content is not None: - power_event, power_context = await create_event( - EventTypes.PowerLevels, pl_content, True - ) - events_to_send.append((power_event, power_context)) + await add_event(EventTypes.PowerLevels, pl_content) else: # If the `initial_state` does not have `EventTypes.PowerLevels` content, # use the default power level content. @@ -1803,49 +1908,33 @@ class RoomCreationHandler: # apply those. Supplied room override wins over server config. if power_level_content_override: power_level_content.update(power_level_content_override) - pl_event, pl_context = await create_event( - EventTypes.PowerLevels, - power_level_content, - True, - ) - events_to_send.append((pl_event, pl_context)) + await add_event(EventTypes.PowerLevels, power_level_content) if room_alias and (EventTypes.CanonicalAlias, "") not in initial_state: - room_alias_event, room_alias_context = await create_event( - EventTypes.CanonicalAlias, {"alias": room_alias.to_string()}, True + await add_event( + EventTypes.CanonicalAlias, {"alias": room_alias.to_string()} ) - events_to_send.append((room_alias_event, room_alias_context)) if (EventTypes.JoinRules, "") not in initial_state: - join_rules_event, join_rules_context = await create_event( - EventTypes.JoinRules, - {"join_rule": preset_config["join_rules"]}, - True, + await add_event( + EventTypes.JoinRules, {"join_rule": preset_config["join_rules"]} ) - events_to_send.append((join_rules_event, join_rules_context)) if (EventTypes.RoomHistoryVisibility, "") not in initial_state: - visibility_event, visibility_context = await create_event( + await add_event( EventTypes.RoomHistoryVisibility, {"history_visibility": preset_config["history_visibility"]}, - True, ) - events_to_send.append((visibility_event, visibility_context)) if preset_config["guest_can_join"]: if (EventTypes.GuestAccess, "") not in initial_state: - guest_access_event, guest_access_context = await create_event( + await add_event( EventTypes.GuestAccess, {EventContentFields.GUEST_ACCESS: GuestAccess.CAN_JOIN}, - True, ) - events_to_send.append((guest_access_event, guest_access_context)) for (etype, state_key), content in initial_state.items(): - event, context = await create_event( - etype, content, True, state_key=state_key - ) - events_to_send.append((event, context)) + await add_event(etype, content, state_key=state_key) # If the client supplied its own `m.room.encryption` event in the # initial state, only let it take precedence over the forced default @@ -1862,26 +1951,19 @@ class RoomCreationHandler: and not ignore_forced_encryption and not supplied_encryption_is_valid ): - encryption_event, encryption_context = await create_event( + await add_event( EventTypes.RoomEncryption, {"algorithm": RoomEncryptionAlgorithms.DEFAULT}, - True, state_key="", ) - events_to_send.append((encryption_event, encryption_context)) if "name" in room_config: name = room_config["name"] - name_event, name_context = await create_event( - EventTypes.Name, - {"name": name}, - True, - ) - events_to_send.append((name_event, name_context)) + await add_event(EventTypes.Name, {"name": name}) if "topic" in room_config: topic = room_config["topic"] - topic_event, topic_context = await create_event( + await add_event( EventTypes.Topic, { EventContentFields.TOPIC: topic, @@ -1890,9 +1972,9 @@ class RoomCreationHandler: EventContentFields.M_TEXT: [{MTextFields.BODY: topic}] }, }, - True, ) - events_to_send.append((topic_event, topic_context)) + + await build_collected_events() for invitee, invite_content in batched_invites: # Check the invite against the auth rules before it joins the batch: @@ -1927,6 +2009,66 @@ class RoomCreationHandler: len(invited_users), ) + def _rust_room_creation_unsupported( + self, + creator: Requester, + room_version: RoomVersion, + persist_create_and_join_alone: bool, + ) -> bool: + """Whether a new room's events must be built in Python even in + `rust_room_creation: on` mode. + + The Rust builder needs the create event and the join in the batch, refers + to events by ID only, and has no state DAG. Events sent by an appservice + carry it in their context. + """ + return ( + persist_create_and_join_alone + or room_version.msc4242_state_dags + or creator.app_service_id is not None + ) + + def _build_room_events_in_rust( + self, + creator: Requester, + room_version: RoomVersion, + room_id: str, + prev_event: list[str], + depth: int, + state_map: StateMap[str], + specs: list[tuple[str, str, JsonMapping]], + ) -> list[EventBase]: + """Build, hash and sign the events of `specs` in one Rust call, as + `create_event` would build each in turn after `prev_event`.""" + # The internal metadata `create_event` gives each event it builds. + internal_metadata = EventInternalMetadata({}) + if creator.device_id is not None: + internal_metadata.device_id = creator.device_id + internal_metadata.outlier = False + + (prev_event_id,) = prev_event + return self._event_signer.build_room_events( + room_version, + room_id, + creator.user.to_string(), + prev_event_id, + depth, + dict(state_map), + [ + (etype, state_key, encode_canonical_json(content)) + for etype, state_key, content in specs + ], + int(self.clock.time_msec()), + internal_metadata.get_dict(), + ) + + async def _check_event_built_in_rust(self, event: EventBase) -> None: + """The checks `create_event` runs on an event of a new room once built.""" + validator = self.event_creation_handler.validator + validator.validate_builder(event) + validator.validate_new(event, self.config) + await self.event_creation_handler._validate_event_relation(event) + def _persist_create_and_join_alone( self, room_version: RoomVersion, initial_state: StateMap[Any] ) -> bool: diff --git a/synapse/synapse_rust/events.pyi b/synapse/synapse_rust/events.pyi index 285af4cd51..013531d22a 100644 --- a/synapse/synapse_rust/events.pyi +++ b/synapse/synapse_rust/events.pyi @@ -558,3 +558,28 @@ class LocalEventSigner: Raises: ValueError: if the JSON is not a valid event for the room version. """ + def build_room_events( + self, + room_version: RoomVersion, + room_id: str, + sender: str, + prev_event_id: str, + depth: int, + state_map: dict[tuple[str, str], str], + specs: list[tuple[str, str, bytes]], + origin_server_ts: int, + internal_metadata_dict: JsonDict, + ) -> list[Event]: + """Build the state events that follow the create event of a new room, + as the room handler's batch builds them one by one: each event's prev + event is the one before it, its depth one more, and its auth events are + picked from the state before it, which starts as `state_map`. + + `specs` are `(type, state_key, content)`, with the content as its + canonical JSON. Every event gets `origin_server_ts` and its own copy of + `internal_metadata_dict`. + + Raises: + ValueError: for anything it cannot build, including v1 and v2 rooms + and MSC4242 state DAG rooms. Build the events in Python then. + """ diff --git a/tests/handlers/test_room_rust_creation.py b/tests/handlers/test_room_rust_creation.py new file mode 100644 index 0000000000..49203edf64 --- /dev/null +++ b/tests/handlers/test_room_rust_creation.py @@ -0,0 +1,322 @@ +# +# 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: +# . +# + +"""`rust_room_creation`: a new room's events built in one Rust call.""" + +from typing import Any +from unittest.mock import Mock, patch + +from twisted.internet.testing import MemoryReactor + +import synapse +from synapse.api.constants import EventTypes +from synapse.api.room_versions import KNOWN_ROOM_VERSIONS +from synapse.events import EventBase +from synapse.handlers.room import rust_room_creation_counter +from synapse.metrics import SERVER_NAME_LABEL +from synapse.rest.client import login, room +from synapse.server import HomeServer +from synapse.types import JsonDict, create_requester +from synapse.util.clock import Clock + +from tests import unittest +from tests.unittest import override_config +from tests.utils import RUST_ROOM_CREATION_FOR_TESTS + +# Room configurations that between them create every kind of event the room +# handler sends before any invites. +ROOM_CONFIGS: list[JsonDict] = [ + {}, + {"preset": "public_chat", "name": "A room", "topic": "A topic"}, + {"preset": "trusted_private_chat", "invite": ["@bob:test"]}, + { + "initial_state": [ + {"type": "m.room.guest_access", "content": {"guest_access": "can_join"}}, + {"type": "m.space.child", "state_key": "!a:test", "content": {"via": []}}, + { + "type": "m.room.power_levels", + "content": {"users_default": 100, "ban": 50}, + }, + {"type": "m.room.encryption", "content": {"algorithm": "m.megolm"}}, + ], + "room_alias_name": "alias", + }, + { + "power_level_content_override": {"events_default": 10}, + "creation_content": {"m.federate": False}, + "is_direct": True, + }, +] + +# Room versions the Rust builder covers, one per event format. +ROOM_VERSIONS = ["3", "10", "11", "12"] + + +def _comparable(event: EventBase, keys: dict[str, tuple[str, str]]) -> JsonDict: + """The event's PDU, with the events it refers to given by their `(type, + state_key)` in `keys`, and without its hashes, signatures and ID. + + Python orders the auth events by iterating a set of `(type, state_key)`, + which changes with the process's hash seed, so the IDs, which cover that + order, cannot be matched. The order means nothing to the protocol, so the + auth events are compared as a set. + """ + pdu = event.get_pdu_json() + pdu["auth_events"] = sorted(keys[e] for e in pdu["auth_events"]) + pdu["prev_events"] = [keys[e] for e in pdu["prev_events"]] + for key in ("hashes", "signatures", "event_id"): + pdu.pop(key, None) + return pdu + + +def _rust_rooms(hs: HomeServer, result: str) -> float: + return rust_room_creation_counter.labels( + result=result, **{SERVER_NAME_LABEL: hs.hostname} + )._value.get() + + +class RustRoomCreationTestCase(unittest.HomeserverTestCase): + if RUST_ROOM_CREATION_FOR_TESTS == "off": + skip = "SYNAPSE_TEST_RUST_ROOM_CREATION turns the Rust room builder off" + + servlets = [ + synapse.rest.admin.register_servlets_for_client_rest_resource, + login.register_servlets, + room.register_servlets, + ] + + def default_config(self) -> JsonDict: + config = super().default_config() + config.setdefault("experimental_features", {}).setdefault( + "rust_room_creation", "on" + ) + return config + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + self.user_id = self.register_user("alice", "pass") + self.tok = self.login("alice", "pass", device_id="DEVICE") + self.register_user("bob", "pass") + self.handler = hs.get_room_creation_handler() + # Strict, whatever SYNAPSE_TEST_RUST_ROOM_CREATION_STRICT says. + self.handler._rust_room_creation_strict = True + self.store = hs.get_datastores().main + + def _create_room(self, room_version: str, config: JsonDict) -> str: + body = dict(config) + body["room_version"] = room_version + if "room_alias_name" in body: + # Aliases are unique. + self._aliases = getattr(self, "_aliases", 0) + 1 + body["room_alias_name"] += str(self._aliases) + channel = self.make_request("POST", "/createRoom", body, access_token=self.tok) + self.assertEqual(channel.code, 200, channel.json_body) + return channel.json_body["room_id"] + + def _room_events(self, room_id: str) -> list[EventBase]: + """The room's events in the order they were sent.""" + rows = self.get_success( + self.store.db_pool.simple_select_list( + "events", {"room_id": room_id}, ("event_id", "stream_ordering") + ) + ) + event_ids = [event_id for event_id, _ in sorted(rows, key=lambda r: r[1])] + return self.get_success(self.store.get_events_as_list(event_ids)) + + def test_same_events_as_python(self) -> None: + """For each configuration and room version, create the room in Python, + then build its events after the create event in Rust from the same + inputs: they come out identical, event IDs included.""" + requester = create_requester(self.user_id, device_id="DEVICE") + for room_version in ROOM_VERSIONS: + for config in ROOM_CONFIGS: + with self.subTest(room_version=room_version, config=config): + self.handler._rust_room_creation = False + try: + room_id = self._create_room(room_version, config) + finally: + self.handler._rust_room_creation = True + + events = self._room_events(room_id) + create, rest = events[0], events[1:] + # Stop at the first invite, which `create_event` builds. + rest = [ + e + for e in rest + if not ( + e.type == EventTypes.Member and e.membership == "invite" + ) + ] + # The clock does not move while the batch is built. + (ts,) = {e.origin_server_ts for e in rest} + + with patch.object( + self.handler.clock, + "time_msec", + return_value=ts, + ): + built = self.handler._build_room_events_in_rust( + requester, + KNOWN_ROOM_VERSIONS[room_version], + room_id, + [create.event_id], + create.depth + 1, + {(create.type, create.state_key): create.event_id}, + [(e.type, e.state_key, e.content) for e in rest], + ) + + self.assertEqual(len(built), len(rest)) + python_keys = {e.event_id: (e.type, e.state_key) for e in events} + rust_keys = { + e.event_id: (e.type, e.state_key) for e in [create] + built + } + for ours, theirs in zip(built, rest): + self.assertEqual( + _comparable(ours, rust_keys), + _comparable(theirs, python_keys), + ) + self.assertEqual(ours.internal_metadata.device_id, "DEVICE") + + def test_rooms_built_in_rust(self) -> None: + """Rooms of every configuration are created with their events built in + Rust, and end up with the state Python would give them.""" + for room_version in ROOM_VERSIONS: + for config in ROOM_CONFIGS: + with self.subTest(room_version=room_version, config=config): + before = _rust_rooms(self.hs, "rust") + room_id = self._create_room(room_version, config) + self.assertEqual(_rust_rooms(self.hs, "rust"), before + 1) + + state = self.get_success( + self.hs.get_storage_controllers().state.get_current_state( + room_id + ) + ) + self.assertEqual( + state[(EventTypes.Member, self.user_id)].membership, "join" + ) + self.assertIn((EventTypes.PowerLevels, ""), state) + + def test_v1_rooms_fall_back(self) -> None: + before = _rust_rooms(self.hs, "fallback") + self._create_room("1", {}) + self.assertEqual(_rust_rooms(self.hs, "fallback"), before + 1) + + def test_aliases_in_initial_state_fall_back(self) -> None: + """`m.room.aliases` needs the join persisted on its own.""" + before = _rust_rooms(self.hs, "fallback") + self._create_room( + "10", + { + "initial_state": [ + {"type": "m.room.aliases", "state_key": "test", "content": {}} + ] + }, + ) + self.assertEqual(_rust_rooms(self.hs, "fallback"), before + 1) + + def test_invalid_initial_state(self) -> None: + """An event `validate_new` rejects fails the request as in Python.""" + channel = self.make_request( + "POST", + "/createRoom", + { + "initial_state": [ + {"type": "m.room.power_levels", "content": {"ban": "high"}} + ] + }, + access_token=self.tok, + ) + self.assertEqual(channel.code, 400, channel.json_body) + self.assertEqual(channel.json_body["errcode"], "M_BAD_JSON") + + def test_create_event_in_initial_state(self) -> None: + """A second create event is refused by `create_event`, as in Python.""" + before = _rust_rooms(self.hs, "fallback") + channel = self.make_request( + "POST", + "/createRoom", + { + "room_version": "10", + "initial_state": [ + {"type": "m.room.create", "content": {"room_version": "10"}} + ], + }, + access_token=self.tok, + ) + self.assertEqual(channel.code, 400, channel.json_body) + self.assertEqual(channel.json_body["errcode"], "M_INVALID_PARAM") + self.assertEqual(_rust_rooms(self.hs, "fallback"), before + 1) + + def test_bad_input_is_not_a_rust_error(self) -> None: + """Input Rust cannot build and Python rejects gets Python's error, even + in strict mode, and does not count as a Rust error.""" + before = _rust_rooms(self.hs, "error") + channel = self.make_request( + "POST", + "/createRoom", + { + "initial_state": [ + { + "type": "m.room.member", + "state_key": "@bob:test", + "content": {"displayname": "no membership"}, + } + ] + }, + access_token=self.tok, + ) + self.assertEqual(channel.code, 400, channel.json_body) + self.assertEqual(_rust_rooms(self.hs, "error"), before) + + def test_rust_error_falls_back(self) -> None: + """Outside strict mode, a Rust failure builds the room in Python.""" + self.handler._rust_room_creation_strict = False + + def fail(*args: Any, **kwargs: Any) -> Any: + raise ValueError("boom") + + before = _rust_rooms(self.hs, "error") + with patch.object(self.handler, "_event_signer") as signer: + signer.build_room_events.side_effect = fail + room_id = self._create_room("10", {"name": "A room"}) + self.assertEqual(_rust_rooms(self.hs, "error"), before + 1) + events = self._room_events(room_id) + self.assertEqual( + [e.type for e in events][:3], + [EventTypes.Create, EventTypes.Member, EventTypes.PowerLevels], + ) + + @override_config({"experimental_features": {"rust_room_creation": "off"}}) + def test_off(self) -> None: + if RUST_ROOM_CREATION_FOR_TESTS: + self.skipTest("SYNAPSE_TEST_RUST_ROOM_CREATION sets the mode") + before = _rust_rooms(self.hs, "rust") + self._create_room("10", {}) + self.assertEqual(_rust_rooms(self.hs, "rust"), before) + + def test_appservice_falls_back(self) -> None: + """An appservice sender's events carry it in their context.""" + appservice = Mock() + appservice.id = "an_appservice" + requester = create_requester(self.user_id, app_service=appservice) + self.assertTrue( + self.handler._rust_room_creation_unsupported( + requester, KNOWN_ROOM_VERSIONS["10"], False + ) + ) + self.assertFalse( + self.handler._rust_room_creation_unsupported( + create_requester(self.user_id), KNOWN_ROOM_VERSIONS["10"], False + ) + ) diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py index bc8b597d62..d9602d961f 100644 --- a/tests/rest/client/test_rooms.py +++ b/tests/rest/client/test_rooms.py @@ -794,10 +794,14 @@ class RoomsCreateTestCase(RoomBase): def _expected_create_room_txn_count(self) -> int: """The Python transactions a plain room creation runs. The state groups - are written in Rust on the native driver, outside them.""" + are written in Rust on the native driver, outside them, and building the + events in Rust skips looking up the room version.""" + count = 10 if self.hs.get_datastores().state._rust_state_group_writer is not None: - return 9 - return 10 + count -= 1 + if self.hs.config.experimental.rust_room_creation == "on": + count -= 1 + return count def test_post_room_no_keys(self) -> None: # POST with no config keys, expect new room id diff --git a/tests/server.py b/tests/server.py index d356826cc5..aec1a87ff2 100644 --- a/tests/server.py +++ b/tests/server.py @@ -97,6 +97,7 @@ from synapse.config.homeserver import HomeServerConfig from synapse.events.auto_accept_invites import InviteAutoAccepter from synapse.events.presence_router import load_legacy_presence_router from synapse.handlers.auth import load_legacy_password_auth_providers +from synapse.handlers.room import RoomCreationHandler from synapse.http.site import SynapseRequest from synapse.logging.context import ContextResourceUsage, LoggingContext from synapse.module_api.callbacks.spamchecker_callbacks import load_legacy_spam_checkers @@ -138,6 +139,8 @@ from tests.utils import ( POSTGRES_USER, RUST_PERSIST_FOR_TESTS, RUST_PERSIST_STRICT_FOR_TESTS, + RUST_ROOM_CREATION_FOR_TESTS, + RUST_ROOM_CREATION_STRICT_FOR_TESTS, RUST_SLIDING_SYNC_FOR_TESTS, RUST_SLIDING_SYNC_STRICT_FOR_TESTS, RUST_STATE_RES_FOR_TESTS, @@ -1415,6 +1418,13 @@ def setup_test_homeserver( Literal["off", "on"], RUST_SLIDING_SYNC_FOR_TESTS ) + if RUST_ROOM_CREATION_FOR_TESTS: + if RUST_ROOM_CREATION_FOR_TESTS not in ("off", "on"): + raise ValueError("SYNAPSE_TEST_RUST_ROOM_CREATION must be off or on") + config.experimental.rust_room_creation = cast( + Literal["off", "on"], RUST_ROOM_CREATION_FOR_TESTS + ) + if USE_POSTGRES_FOR_TESTS: test_db = "synapse_test_%s" % uuid.uuid4().hex @@ -1643,6 +1653,11 @@ def setup_test_homeserver( ) sync_handler._rust_sync_strict = RUST_SYNC_STRICT_FOR_TESTS + if RUST_ROOM_CREATION_FOR_TESTS: + RoomCreationHandler._rust_room_creation_strict = ( + RUST_ROOM_CREATION_STRICT_FOR_TESTS + ) + if RUST_SLIDING_SYNC_FOR_TESTS: # Likewise for the Rust sliding sync handler. sliding_sync_handler = hs.get_sliding_sync_handler() diff --git a/tests/utils.py b/tests/utils.py index e452685af8..0b9faee553 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -78,6 +78,13 @@ RUST_SLIDING_SYNC_FOR_TESTS = os.environ.get("SYNAPSE_TEST_RUST_SLIDING_SYNC") RUST_SLIDING_SYNC_STRICT_FOR_TESTS = ( os.environ.get("SYNAPSE_TEST_RUST_SLIDING_SYNC_STRICT", "1") != "0" ) +# As above, for `experimental_features.rust_room_creation` ("off" or "on"). +RUST_ROOM_CREATION_FOR_TESTS = os.environ.get("SYNAPSE_TEST_RUST_ROOM_CREATION") +# A Rust error fails the test rather than falling back to building the room's +# events in Python. Set to 0 to fall back and count, as production does. +RUST_ROOM_CREATION_STRICT_FOR_TESTS = ( + os.environ.get("SYNAPSE_TEST_RUST_ROOM_CREATION_STRICT", "1") != "0" +) # At `rust_federation_sending: transactions`, check every transaction body Rust # builds against the one Python would build, and fail the test if they differ. RUST_FED_STRICT_FOR_TESTS = os.environ.get("SYNAPSE_TEST_RUST_FED_STRICT", "1") != "0"