From d7febaf6cfefc67ef3f25357bdd10f1fcbed69fb Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:58:20 +0100 Subject: [PATCH 01/62] Add is_state_dag_connected to check the events returned in /send_join --- synapse/handlers/federation_event.py | 16 +++++++ tests/handlers/test_federation_event.py | 60 ++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 7cbc52d350e..dfdd6b259bf 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -61,6 +61,7 @@ validate_event_for_room_version, ) from synapse.events import EventBase +from synapse.events.py_protocol import MSC4242Event from synapse.events.snapshot import ( EventContext, EventPersistencePair, @@ -2428,3 +2429,18 @@ def _sanity_check_event(self, ev: EventBase) -> None: len(ev.auth_event_ids()), ) raise SynapseError(HTTPStatus.BAD_REQUEST, "Too many auth_events") + + +def is_state_dag_connected(state_dag: Collection[MSC4242Event]) -> bool: + """Returns True if the given events form a connected state DAG (MSC4242). + + The DAG is connected if every event referenced in a `prev_state_events` field is + itself present in `state_dag`, i.e. there are no gaps in the DAG. + """ + have_event_ids = {ev.event_id for ev in state_dag} + all_prev_state_events = { + prev_state_event_id + for ev in state_dag + for prev_state_event_id in ev.prev_state_events + } + return all_prev_state_events.issubset(have_event_ids) diff --git a/tests/handlers/test_federation_event.py b/tests/handlers/test_federation_event.py index b423948096b..0e15c8d7447 100644 --- a/tests/handlers/test_federation_event.py +++ b/tests/handlers/test_federation_event.py @@ -23,13 +23,15 @@ from twisted.internet.testing import MemoryReactor from synapse.api.errors import AuthError, StoreError -from synapse.api.room_versions import RoomVersion +from synapse.api.room_versions import RoomVersion, RoomVersions from synapse.event_auth import ( check_state_dependent_auth_rules, check_state_independent_auth_rules, ) +from synapse.events.py_protocol import MSC4242Event, supports_msc4242_state_dag from synapse.events.snapshot import EventContext from synapse.federation.transport.client import StateRequestResponse +from synapse.handlers.federation_event import is_state_dag_connected from synapse.logging.context import LoggingContext from synapse.rest import admin from synapse.rest.client import login, room @@ -1182,3 +1184,59 @@ async def get_room_state( bert_member_event.event_id, "Rejected kick event unexpectedly became part of room state.", ) + + +class IsStateDagConnectedTests(unittest.TestCase): + def setUp(self) -> None: + super().setUp() + self.origin_server_ts = 0 + + def _make_event(self, prev_state_events: list[str]) -> MSC4242Event: + """Build an MSC4242 event with the given `prev_state_events`.""" + # Event IDs are hashes of the event, so vary a field to keep them distinct. + self.origin_server_ts += 1 + event = make_test_event( + { + "type": "m.room.topic", + "state_key": "", + "content": {}, + "sender": "@alice:test", + "origin_server_ts": self.origin_server_ts, + "prev_state_events": prev_state_events, + }, + room_version=RoomVersions.MSC4242v12, + ) + assert supports_msc4242_state_dag(event) + return event + + def test_linear(self) -> None: + create = self._make_event([]) + second = self._make_event([create.event_id]) + third = self._make_event([second.event_id]) + + self.assertTrue(is_state_dag_connected([third, create, second])) + + def test_fork_and_merge(self) -> None: + create = self._make_event([]) + fork_one = self._make_event([create.event_id]) + fork_two = self._make_event([create.event_id]) + fork_three = self._make_event([create.event_id]) + merge = self._make_event([fork_one.event_id, fork_three.event_id]) + + self.assertTrue( + is_state_dag_connected([create, fork_one, fork_two, fork_three, merge]) + ) + + def test_missing_prev_state_event(self) -> None: + create = self._make_event([]) + second = self._make_event([create.event_id]) + unconnected = self._make_event(["$unknown"]) + + self.assertFalse(is_state_dag_connected([create, second, unconnected])) + + def test_missing_intermediate_event(self) -> None: + create = self._make_event([]) + second = self._make_event([create.event_id]) + third = self._make_event([second.event_id]) + + self.assertFalse(is_state_dag_connected([create, third])) From 67fc7516c7941d93c3870128d68b04b0c30eaba0 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:59:11 +0100 Subject: [PATCH 02/62] Calculate auth events on events and auth based on them --- synapse/handlers/federation_event.py | 61 ++++++++++++++++++++-- tests/federation/test_federation_server.py | 3 -- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index dfdd6b259bf..13e944a14c5 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -61,7 +61,7 @@ validate_event_for_room_version, ) from synapse.events import EventBase -from synapse.events.py_protocol import MSC4242Event +from synapse.events.py_protocol import MSC4242Event, supports_msc4242_state_dag from synapse.events.snapshot import ( EventContext, EventPersistencePair, @@ -1834,6 +1834,11 @@ async def _check_event_auth( """ Checks whether an event should be rejected (for failing auth checks). + For MSC4242 State DAG rooms the auth events are calculated from the state before + the event rather than taken from the event, so checking the event against its auth + events (step 4) is the same as checking it against the state before it (step 5), + and only step 4 is performed. + Args: origin: The host the event originates from. This is used to fetch any missing auth events. It can be set to None, but only if we are @@ -1874,7 +1879,7 @@ async def _check_event_auth( # caller rather than swallow with `context.rejected` (since we cannot be # certain that there is a permanent problem with the event). claimed_auth_events = await self._load_or_fetch_auth_events_for_event( - origin, event + origin, event, context ) set_tag( SynapseTags.RESULT_PREFIX + "claimed_auth_events", @@ -1899,6 +1904,9 @@ async def _check_event_auth( context.rejected = RejectedReason.AUTH_ERROR return + if supports_msc4242_state_dag(event): + return + # now check the auth rules pass against the room state before the event # https://spec.matrix.org/v1.3/server-server-api/#checks-performed-on-receipt-of-a-pdu: # 5. Passes authorization rules based on the state before the event, @@ -2112,7 +2120,7 @@ async def _check_for_soft_fail( event.internal_metadata.soft_failed = True async def _load_or_fetch_auth_events_for_event( - self, destination: str | None, event: EventBase + self, destination: str | None, event: EventBase, context: EventContext ) -> Collection[EventBase]: """Fetch this event's auth_events, from database or remote @@ -2125,6 +2133,10 @@ async def _load_or_fetch_auth_events_for_event( to `destination`, or we couldn't validate the signature on the event (which in turn has multiple potential causes). + MSC4242 State DAG events do not list their auth events, so instead we make sure we + have all of the event's `prev_state_events` and then calculate the auth events from + the state before the event. + Args: destination: where to send the /event_auth request. Typically the server that sent us `event` in the first place. @@ -2134,8 +2146,11 @@ async def _load_or_fetch_auth_events_for_event( event: the event whose auth_events we want + context: the state before the event. + Returns: - all of the events listed in `event.auth_events_ids`, after deduplication + all of the events listed in `event.auth_events_ids`, after deduplication. For + MSC4242 State DAG events, the calculated auth events. Raises: AssertionError if some auth events were missing and no `destination` was @@ -2143,7 +2158,11 @@ async def _load_or_fetch_auth_events_for_event( AuthError if we were unable to fetch the auth_events for any reason. """ - event_auth_event_ids = set(event.auth_event_ids()) + event_auth_event_ids = set( + event.prev_state_events + if supports_msc4242_state_dag(event) + else event.auth_event_ids() + ) event_auth_events = await self._store.get_events( event_auth_event_ids, allow_rejected=True ) @@ -2151,6 +2170,8 @@ async def _load_or_fetch_auth_events_for_event( event_auth_events.keys() ) if not missing_auth_event_ids: + if supports_msc4242_state_dag(event): + return await self._load_calculated_auth_events(event, context) return event_auth_events.values() if destination is None: # this shouldn't happen: destination must be set unless we know we have already @@ -2181,6 +2202,8 @@ async def _load_or_fetch_auth_events_for_event( missing_auth_event_ids.difference_update(extra_auth_events.keys()) event_auth_events.update(extra_auth_events) if not missing_auth_event_ids: + if supports_msc4242_state_dag(event): + return await self._load_calculated_auth_events(event, context) return event_auth_events.values() # we still don't have all the auth events. @@ -2194,6 +2217,34 @@ async def _load_or_fetch_auth_events_for_event( # instead we raise an AuthError, which will make the caller ignore it. raise AuthError(code=HTTPStatus.FORBIDDEN, msg="Auth events could not be found") + async def _load_calculated_auth_events( + self, event: MSC4242Event, context: EventContext + ) -> Collection[EventBase]: + """Calculate the auth events for an MSC4242 State DAG event and load them. + + MSC4242 events do not list their auth events, so they are calculated from the + state before the event, in the same way `EventBuilder.build` does when creating + events locally. The calculated auth event IDs are remembered on the event so we + don't need to calculate them again when the event is persisted. + + Rejected events never form part of the room state, so the calculated auth events + cannot be rejected and are loaded without allowing rejected events. + + Args: + event: the event whose auth events we want. + context: the state before the event. + + Returns: + The calculated auth events. + """ + state_ids = await context.get_prev_state_ids() + calculated_auth_event_ids = self._event_auth_handler.compute_auth_events( + event, state_ids + ) + event.internal_metadata.calculated_auth_event_ids = calculated_auth_event_ids + calculated_auth_events = await self._store.get_events(calculated_auth_event_ids) + return calculated_auth_events.values() + @trace @tag_args async def _get_remote_auth_chain_for_event( diff --git a/tests/federation/test_federation_server.py b/tests/federation/test_federation_server.py index 61dcb7446b4..c5bcf9232c7 100644 --- a/tests/federation/test_federation_server.py +++ b/tests/federation/test_federation_server.py @@ -20,7 +20,6 @@ # import logging from http import HTTPStatus -from unittest import skip as skip_test from unittest.mock import Mock from parameterized import parameterized @@ -1234,7 +1233,6 @@ def test_send_join_without_frozen_dicts(self, room_version: str) -> None: """Test send_join with USE_FROZEN_DICTS=False""" self._test_send_join_common(room_version) - @skip_test("requires MSC4242 inbound event auth") @override_config({"experimental_features": {"msc4242_enabled": True}}) def test_send_join_state_dag(self) -> None: """ @@ -1246,7 +1244,6 @@ def test_send_join_state_dag(self) -> None: """ self._test_send_join_common(RoomVersions.MSC4242v12.identifier) - @skip_test("requires MSC4242 inbound event auth") @override_config({"experimental_features": {"msc4242_enabled": True}}) def test_send_join_state_dag_ignores_partial_state(self) -> None: # FIXME: when we support partial joins this test can be deleted From 2a58c730263ceece13f0ab49990d089fa79930ce Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:35:49 +0100 Subject: [PATCH 03/62] Auth and persist state DAG outliers --- synapse/handlers/federation_event.py | 306 +++++++++++++++++++++++++-- 1 file changed, 291 insertions(+), 15 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 13e944a14c5..aed64ec8f49 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1687,12 +1687,16 @@ async def get_event(event_id: str) -> None: @trace async def _auth_and_persist_outliers( - self, room_id: str, events: Iterable[EventBase] - ) -> None: + self, + room_id: str, + events: Iterable[EventBase], + from_send_join: bool = False, + ) -> bool: """Persist a batch of outlier events fetched from remote servers. We first sort the events to make sure that we process each event's auth_events - before the event itself. + before the event itself. For MSC4242 State DAG events this means sorting by + `prev_state_events`, which is also where auth events are calculated from. We then mark the events as outliers, persist them to the database, and, where appropriate (eg, an invite), awake the notifier. @@ -1701,8 +1705,20 @@ async def _auth_and_persist_outliers( room_id: the room that the events are meant to be in (though this has not yet been checked) events: the events that have been fetched + from_send_join: If True, `events` is the complete state DAG from a /send_join + response, which allows the events to be processed as a batch rather than + one at a time. Only used for MSC4242 State DAG rooms. + Returns: + True if any of the events were rejected. """ event_map = {event.event_id: event for event in events} + if not event_map: + return False + + # There must be an event in events else we would have returned by now + is_state_dag_room = next( + iter(event_map.values()) + ).room_version.msc4242_state_dags event_ids = event_map.keys() set_tag( @@ -1729,26 +1745,39 @@ async def _auth_and_persist_outliers( # We need to persist an event's auth events before the event. auth_graph = { - ev.event_id: [e_id for e_id in ev.auth_event_ids() if e_id in event_map] + ev.event_id: [ + e_id + for e_id in ( + ev.prev_state_events + if supports_msc4242_state_dag(ev) + else ev.auth_event_ids() + ) + if e_id in event_map + ] for ev in event_map.values() } - sorted_auth_event_ids = sorted_topologically(event_map.keys(), auth_graph) - sorted_auth_events = [event_map[e_id] for e_id in sorted_auth_event_ids] + sorted_event_ids = sorted_topologically(event_map.keys(), auth_graph) + sorted_events = [event_map[e_id] for e_id in sorted_event_ids] logger.info( "Persisting %i remaining outliers: %s", - len(sorted_auth_events), - shortstr(e.event_id for e in sorted_auth_events), + len(sorted_events), + shortstr(e.event_id for e in sorted_events), ) + if is_state_dag_room: + return await self._auth_and_persist_state_dag_outliers( + room_id, event_map, sorted_events, from_send_join + ) + + events_and_contexts_to_persist: list[EventPersistencePair] = [] + # get all the auth events for all the events in this batch. By now, they should # have been persisted. auth_event_ids = { - aid for event in sorted_auth_events for aid in event.auth_event_ids() + aid for event in sorted_events for aid in event.auth_event_ids() } auth_map = { - ev.event_id: ev - for ev in sorted_auth_events - if ev.event_id in auth_event_ids + ev.event_id: ev for ev in sorted_events if ev.event_id in auth_event_ids } missing_events = auth_event_ids.difference(auth_map) @@ -1760,8 +1789,6 @@ async def _auth_and_persist_outliers( ) auth_map.update(persisted_events) - events_and_contexts_to_persist: list[EventPersistencePair] = [] - async def prep(event: EventBase) -> None: with nested_logging_context(suffix=event.event_id): auth = [] @@ -1807,7 +1834,7 @@ async def prep(event: EventBase) -> None: events_and_contexts_to_persist.append((event, context)) - for i, event in enumerate(sorted_auth_events): + for i, event in enumerate(sorted_events): await prep(event) # The above function is typically not async, and so won't yield to @@ -1816,6 +1843,11 @@ async def prep(event: EventBase) -> None: if (i + 1) % 1000 == 0: await self._clock.sleep(Duration(seconds=0)) + has_rejected_events = any( + context.rejected is not None + for _, context in events_and_contexts_to_persist + ) + # Also persist the new event in batches for similar reasons as above. for batch in batch_iter(events_and_contexts_to_persist, 1000): await self.persist_events_and_notify( @@ -1827,6 +1859,250 @@ async def prep(event: EventBase) -> None: backfilled=True, ) + return has_rejected_events + + async def _auth_and_persist_state_dag_outliers( + self, + room_id: str, + event_map: dict[str, EventBase], + sorted_events: list[EventBase], + from_send_join: bool, + ) -> bool: + """Persist a batch of MSC4242 State DAG outlier events. + + Other room versions check that an event is allowed according to the auth events + the event itself lists. MSC4242 rooms instead calculate the auth state before the + event and then determine if the event is allowed. + + This means we normally cannot prep the whole batch and then persist the whole + batch, as the state at an event's `prev_state_events` has to have been persisted + before we can work out that event's auth events. The exception is a /send_join + response, where the entire state DAG is in `event_map` and so the state at each + event can be remembered as we go. + + Params: + room_id: the room that the events are meant to be in (though this has + not yet been checked) + event_map: all of the events being persisted, by event ID + sorted_events: the events being persisted, sorted so that an event's + `prev_state_events` come before it + from_send_join: True if `event_map` is the complete state DAG from a + /send_join response + Returns: + True if any of the events were rejected. + """ + has_rejected_events = False + events_and_contexts_to_persist: list[EventPersistencePair] = [] + event_id_to_state_group: dict[str, int] = {} + state_group_to_state_map: dict[int, StateMap[str]] = {} + processed_event_map: dict[str, EventBase] = {} + + async def process(event: EventBase) -> EventPersistencePair: + assert supports_msc4242_state_dag(event) + with nested_logging_context(suffix=event.event_id): + # We can only use the state we have remembered if we have remembered it + # for every one of the event's `prev_state_events`. If we haven't, the + # missing ones were already persisted, so use the database instead. + known_prev_state_maps = None + if from_send_join and all( + prev_state_event_id in event_id_to_state_group + for prev_state_event_id in event.prev_state_events + ): + known_prev_state_maps = { + event_id_to_state_group[prev_state_event_id]: ( + state_group_to_state_map[ + event_id_to_state_group[prev_state_event_id] + ] + ) + for prev_state_event_id in event.prev_state_events + } + + ( + context, + calculated_auth_event_ids, + ) = await self._calculate_state_dag_context( + event, + known_prev_state_maps=known_prev_state_maps, + event_map=processed_event_map, + ) + event.internal_metadata.calculated_auth_event_ids = ( + calculated_auth_event_ids + ) + event.internal_metadata.outlier = True + + batched_auth_events = None + if from_send_join: + # The events in this batch aren't persisted yet, so pull the auth + # events out of the batch, falling back to the database for events we + # had already seen and hence filtered out of the batch. + calculated_auth_events = { + event_id: event_map[event_id] + for event_id in calculated_auth_event_ids + if event_id in event_map + } + missing_auth_event_ids = set(calculated_auth_event_ids).difference( + calculated_auth_events + ) + if missing_auth_event_ids: + calculated_auth_events.update( + await self._store.get_events( + missing_auth_event_ids, + allow_rejected=True, + redact_behaviour=EventRedactBehaviour.as_is, + ) + ) + batched_auth_events = { + event_id: event_map[event_id] + for event_id in itertools.chain( + calculated_auth_event_ids, event.prev_state_events + ) + if event_id in event_map + } + if context.state_group is not None: + event_id_to_state_group[event.event_id] = context.state_group + state_group_to_state_map[ + context.state_group + ] = await context.get_current_state_ids() + else: + calculated_auth_events = await self._store.get_events( + calculated_auth_event_ids, + allow_rejected=True, + redact_behaviour=EventRedactBehaviour.as_is, + ) + + try: + validate_event_for_room_version(event) + await check_state_independent_auth_rules( + self._store, event, batched_auth_events + ) + check_state_dependent_auth_rules( + event, calculated_auth_events.values() + ) + except AuthError as e: + logger.warning("Rejecting %r because %s", event, e) + context.rejected = RejectedReason.AUTH_ERROR + except EventSizeError as e: + if e.unpersistable: + # This event is completely unpersistable. + raise e + # Otherwise, we are somewhat lenient and just persist the event + # as rejected, for moderate compatibility with older Synapse + # versions. + logger.warning("While validating received event %r: %s", event, e) + context.rejected = RejectedReason.OVERSIZED_EVENT + + processed_event_map[event.event_id] = event + return event, context + + if from_send_join: + for i, event in enumerate(sorted_events): + event_and_context = await process(event) + if event_and_context[1].rejected is not None: + has_rejected_events = True + events_and_contexts_to_persist.append(event_and_context) + + # The above function is typically not async, and so won't yield to + # the reactor. For large rooms let's yield to the reactor + # occasionally to ensure we don't block other work. + if (i + 1) % 1000 == 0: + await self._clock.sleep(Duration(seconds=0)) + + for batch in batch_iter(events_and_contexts_to_persist, 1000): + await self.persist_events_and_notify( + room_id, + batch, + # Mark these events as backfilled as they're historic events that + # will eventually be backfilled. For example, missing events we + # fetch during backfill should be marked as backfilled as well. + backfilled=True, + ) + else: + for event in sorted_events: + event_and_context = await process(event) + if event_and_context[1].rejected is not None: + has_rejected_events = True + await self.persist_events_and_notify( + room_id, + [event_and_context], + # Mark these events as backfilled as they're historic events that + # will eventually be backfilled. For example, missing events we + # fetch during backfill should be marked as backfilled as well. + backfilled=True, + ) + + return has_rejected_events + + async def _calculate_state_dag_context( + self, + event: MSC4242Event, + known_prev_state_maps: dict[int, StateMap[str]] | None = None, + event_map: dict[str, EventBase] | None = None, + ) -> tuple[EventContext, list[str]]: + """Calculate the context and auth events for an MSC4242 State DAG event. + + The state before the event is resolved from the event's `prev_state_events`, and + the auth events are then calculated from that state, in the same way + `EventBuilder.build` does when creating events locally. This is the expensive + bit, but it's a one-time cost as the calculated auth events are remembered on + the event. + + Args: + event: The event to calculate the context for. + known_prev_state_maps: The state maps at all of the event's + `prev_state_events`, keyed by state group, if known. This allows events + which have not been persisted yet to be processed. + event_map: Events which may be needed to resolve the state but which have not + been persisted yet. + Returns: + The persisted EventContext and the calculated auth event IDs. + + Raises: + SynapseError if the event has no `prev_state_events` and is not a create + event. + """ + if len(event.prev_state_events) == 0 and event.type != EventTypes.Create: + raise SynapseError(502, f"event {event.event_id} has no prev_state_events") + + if known_prev_state_maps: + if len(known_prev_state_maps) == 1: + state_ids = list(known_prev_state_maps.values())[0] + else: + res = await self._state_resolution_handler.resolve_state_groups( + event.room_id, + event.room_version.identifier, + known_prev_state_maps, + event_map=event_map, + state_res_store=StateResolutionStore( + self._store, self._state_deletion_store + ), + ) + state_ids = await res.get_state(self._state_storage_controller) + else: + state_ids = await self._state_handler.compute_state_after_events( + event.room_id, + event.prev_state_events, + # We cannot filter the state as we need to persist the state group. + state_filter=None, + await_full_state=False, + ) + + # We should always have some resolved state after the prev_state_events, except + # for the create event which is the start of the state DAG. + is_create_event = ( + len(event.prev_state_events) == 0 and event.type == EventTypes.Create + ) + if not is_create_event: + assert len(state_ids) > 0 + + context = await self._state_handler.compute_event_context( + event, + state_ids, + # `compute_event_context` asserts this is None if there is no state, which + # is the case for the create event. + partial_state=None if is_create_event else False, + ) + return context, self._event_auth_handler.compute_auth_events(event, state_ids) + @trace async def _check_event_auth( self, origin: str | None, event: EventBase, context: EventContext From 0f8cfcba134631d41eef0e29b000a0ee459eaf05 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:24:54 +0100 Subject: [PATCH 04/62] Support joining MSC4242 rooms --- synapse/handlers/federation.py | 12 ++++------ synapse/handlers/federation_event.py | 34 ++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 655e8d244aa..3558e0bd6b8 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -60,7 +60,6 @@ PartialStateConflictError, RequestSendFailed, SynapseError, - UnsupportedRoomVersionError, ) from synapse.api.room_versions import KNOWN_ROOM_VERSIONS, RoomVersion from synapse.crypto.event_signing import compute_event_signature @@ -678,12 +677,6 @@ async def do_invite_join( room_id ) - # See related restriction in /createRoom requests in handlers/room.py - if room_version_obj.msc4242_state_dags: - raise UnsupportedRoomVersionError( - "Homeserver does not support this room version over federation" - ) - ret = await self.federation_client.send_join( host_list, event, @@ -707,6 +700,11 @@ async def do_invite_join( auth_chain = ret.auth_chain auth_chain.sort(key=lambda e: e.depth) + if ret.state_dag is not None: + # MSC4242 State DAG rooms have no separate state and auth chain: the + # state DAG is both, and everything is derived from it. + state = ret.state_dag + logger.debug("do_invite_join auth_chain: %s", auth_chain) logger.debug("do_invite_join state: %s", state) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index aed64ec8f49..2248c0fbee9 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -523,8 +523,10 @@ async def process_remote_join( Args: origin: Where the events came from room_id: - auth_events - state + auth_events: The auth chain from the send_join response. Empty for MSC4242 + State DAG rooms, which do not have one. + state: The current state of the room. For MSC4242 State DAG rooms this is the + state DAG, which includes the auth chains for the state. event room_version: The room version we expect this room to have, and will raise if it doesn't match the version in the create event. @@ -556,6 +558,13 @@ async def process_remote_join( if room_version.identifier != room_version_id: raise SynapseError(400, "Room version mismatch") + if room_version.msc4242_state_dags and not is_state_dag_connected( + [ev for ev in state if supports_msc4242_state_dag(ev)] + ): + # We should have been given a connected state DAG. If there is a gap in it we + # cannot calculate the state, so the response is invalid and we refuse the join. + raise SynapseError(502, "State DAG is not connected") + # persist the auth chain and state events. # # any invalid events here will be marked as rejected, and we'll carry on. @@ -567,9 +576,14 @@ async def process_remote_join( # signatures right now doesn't mean that we will *never* be able to, so it # is premature to reject them. # - await self._auth_and_persist_outliers( - room_id, itertools.chain(auth_events, state) + has_rejected_events = await self._auth_and_persist_outliers( + room_id, + itertools.chain(auth_events, state), + from_send_join=True, ) + if room_version.msc4242_state_dags and has_rejected_events: + # The state DAG must not include rejected events + raise SynapseError(502, "State DAG included rejected events") # and now persist the join event itself. logger.info( @@ -621,6 +635,18 @@ async def process_remote_join( ), ) ) + elif supports_msc4242_state_dag(event): + # We don't blindly trust the state the remote server claims is current, + # and instead calculate the state before the join ourselves, which we can + # do now that the whole state DAG has been persisted. + state_before_event = ( + await self._state_handler.resolve_state_groups_for_events( + event.room_id, event.prev_state_events + ) + ) + state_ids_before_event = await state_before_event.get_state( + self._state_storage_controller + ) else: state_ids_before_event = { (e.type, e.state_key): e.event_id for e in state From b47fe5331d813364a5048ff6693eebb86f3cacdf Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:25:04 +0100 Subject: [PATCH 05/62] changelog --- changelog.d/20194.misc | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/20194.misc diff --git a/changelog.d/20194.misc b/changelog.d/20194.misc new file mode 100644 index 00000000000..40cdf30f80e --- /dev/null +++ b/changelog.d/20194.misc @@ -0,0 +1 @@ +Add support for joining [MSC4242](https://github.com/matrix-org/matrix-spec-proposals/pull/4242) rooms over federation. From 6cd5439dc8cd7703e0f20718b619ff51eb585b37 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:30:01 +0100 Subject: [PATCH 06/62] Apply batched suggestions from code review Co-authored-by: Eric Eastwood --- synapse/handlers/federation_event.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 2248c0fbee9..c682a561ab4 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -563,7 +563,7 @@ async def process_remote_join( ): # We should have been given a connected state DAG. If there is a gap in it we # cannot calculate the state, so the response is invalid and we refuse the join. - raise SynapseError(502, "State DAG is not connected") + raise SynapseError(502, "Unable to join because the remote server passed back a state DAG that is not connected (invalid)") # persist the auth chain and state events. # @@ -583,7 +583,7 @@ async def process_remote_join( ) if room_version.msc4242_state_dags and has_rejected_events: # The state DAG must not include rejected events - raise SynapseError(502, "State DAG included rejected events") + raise SynapseError(502, "Unable to join because the remote server passed back a state DAG that includes rejected events (invalid)") # and now persist the join event itself. logger.info( @@ -2005,7 +2005,7 @@ async def process(event: EventBase) -> EventPersistencePair: event, calculated_auth_events.values() ) except AuthError as e: - logger.warning("Rejecting %r because %s", event, e) + logger.warning("Rejecting %r while persisting state DAG because %s", event, e) context.rejected = RejectedReason.AUTH_ERROR except EventSizeError as e: if e.unpersistable: @@ -2136,7 +2136,7 @@ async def _check_event_auth( """ Checks whether an event should be rejected (for failing auth checks). - For MSC4242 State DAG rooms the auth events are calculated from the state before + For MSC4242 State DAG rooms, the auth events are calculated from the state before the event rather than taken from the event, so checking the event against its auth events (step 4) is the same as checking it against the state before it (step 5), and only step 4 is performed. From e7c915e644659a6dbe600eafa916fbf311167fa4 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:50:02 +0100 Subject: [PATCH 07/62] Review comments --- synapse/handlers/federation_event.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index c682a561ab4..b889e1061ef 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1973,6 +1973,9 @@ async def process(event: EventBase) -> EventPersistencePair: calculated_auth_events.update( await self._store.get_events( missing_auth_event_ids, + # Allow rejected events so events which depend on them fail with coherent + # error messages. If we filtered them out here, we'd instead fail with + # a missing events error which is misleading. allow_rejected=True, redact_behaviour=EventRedactBehaviour.as_is, ) @@ -1992,6 +1995,9 @@ async def process(event: EventBase) -> EventPersistencePair: else: calculated_auth_events = await self._store.get_events( calculated_auth_event_ids, + # Allow rejected events so events which depend on them fail with coherent + # error messages. If we filtered them out here, we'd instead fail with + # a missing events error which is misleading. allow_rejected=True, redact_behaviour=EventRedactBehaviour.as_is, ) @@ -2107,7 +2113,7 @@ async def _calculate_state_dag_context( state_ids = await self._state_handler.compute_state_after_events( event.room_id, event.prev_state_events, - # We cannot filter the state as we need to persist the state group. + # There is no filter to apply here state_filter=None, await_full_state=False, ) @@ -2115,9 +2121,13 @@ async def _calculate_state_dag_context( # We should always have some resolved state after the prev_state_events, except # for the create event which is the start of the state DAG. is_create_event = ( - len(event.prev_state_events) == 0 and event.type == EventTypes.Create + len(event.prev_state_events) == 0 + and event.type == EventTypes.Create + and event.get_state_key() == "" ) if not is_create_event: + # We must have calculated room state for every event by now. + # Only the create event can have no room state (since it's the first event in the room) assert len(state_ids) > 0 context = await self._state_handler.compute_event_context( @@ -2136,11 +2146,6 @@ async def _check_event_auth( """ Checks whether an event should be rejected (for failing auth checks). - For MSC4242 State DAG rooms, the auth events are calculated from the state before - the event rather than taken from the event, so checking the event against its auth - events (step 4) is the same as checking it against the state before it (step 5), - and only step 4 is performed. - Args: origin: The host the event originates from. This is used to fetch any missing auth events. It can be set to None, but only if we are @@ -2207,6 +2212,10 @@ async def _check_event_auth( return if supports_msc4242_state_dag(event): + # For MSC4242 State DAG rooms, the auth events are calculated from the state before + # the event rather than taken from the event, so checking the event against its auth + # events (step 4) is the same as checking it against the state before it (step 5), + # and only step 4 is performed. return # now check the auth rules pass against the room state before the event @@ -2544,7 +2553,9 @@ async def _load_calculated_auth_events( event, state_ids ) event.internal_metadata.calculated_auth_event_ids = calculated_auth_event_ids - calculated_auth_events = await self._store.get_events(calculated_auth_event_ids) + calculated_auth_events = await self._store.get_events( + calculated_auth_event_ids, allow_rejected=False + ) return calculated_auth_events.values() @trace From f92d4bfb0c17bbf46167a8f65e09980d97c2d939 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:35:09 +0100 Subject: [PATCH 08/62] Review comments --- synapse/handlers/federation_event.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index b889e1061ef..f64d02c7482 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1921,6 +1921,7 @@ async def _auth_and_persist_state_dag_outliers( events_and_contexts_to_persist: list[EventPersistencePair] = [] event_id_to_state_group: dict[str, int] = {} state_group_to_state_map: dict[int, StateMap[str]] = {} + # Tracks which events have been processed in causal order (prev_state_events first) processed_event_map: dict[str, EventBase] = {} async def process(event: EventBase) -> EventPersistencePair: @@ -1958,6 +1959,8 @@ async def process(event: EventBase) -> EventPersistencePair: batched_auth_events = None if from_send_join: + # event_map is the complete state DAG + # The events in this batch aren't persisted yet, so pull the auth # events out of the batch, falling back to the database for events we # had already seen and hence filtered out of the batch. @@ -1966,6 +1969,8 @@ async def process(event: EventBase) -> EventPersistencePair: for event_id in calculated_auth_event_ids if event_id in event_map } + # In theory we should not be missing any auth events because event_map contains + # the entire state DAG, but if we do we can ask the database if it knows about them. missing_auth_event_ids = set(calculated_auth_event_ids).difference( calculated_auth_events ) @@ -1980,6 +1985,9 @@ async def process(event: EventBase) -> EventPersistencePair: redact_behaviour=EventRedactBehaviour.as_is, ) ) + # In order to run auth rule checks we need the events referenced in the event + # (prev_state_events) and the events we calculated (auth_events) so load them now. + # If we are missing any, auth rule checks will produce a coherent error message. batched_auth_events = { event_id: event_map[event_id] for event_id in itertools.chain( @@ -1987,6 +1995,10 @@ async def process(event: EventBase) -> EventPersistencePair: ) if event_id in event_map } + # If we have calculated state before this event, remember it so we don't need + # to keep asking the database. The state we are remembering is StateMap[event_id] + # along with the state group integer, which is exactly the data needed by + # _calculate_state_dag_context if context.state_group is not None: event_id_to_state_group[event.event_id] = context.state_group state_group_to_state_map[ @@ -2115,6 +2127,9 @@ async def _calculate_state_dag_context( event.prev_state_events, # There is no filter to apply here state_filter=None, + # we need to have finished processing the entire state DAG before we can + # compute the state for new events, but this function is called WHEN we are processing + # the entire state DAG. To wait here would be circular, we'd deadlock. await_full_state=False, ) From 378ba0352e2b800fe1d3c2cce47589c7af654f77 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:36:28 +0100 Subject: [PATCH 09/62] Linting --- synapse/handlers/federation_event.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index f64d02c7482..9f3615e482c 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -563,7 +563,10 @@ async def process_remote_join( ): # We should have been given a connected state DAG. If there is a gap in it we # cannot calculate the state, so the response is invalid and we refuse the join. - raise SynapseError(502, "Unable to join because the remote server passed back a state DAG that is not connected (invalid)") + raise SynapseError( + 502, + "Unable to join because the remote server passed back a state DAG that is not connected (invalid)", + ) # persist the auth chain and state events. # @@ -583,7 +586,10 @@ async def process_remote_join( ) if room_version.msc4242_state_dags and has_rejected_events: # The state DAG must not include rejected events - raise SynapseError(502, "Unable to join because the remote server passed back a state DAG that includes rejected events (invalid)") + raise SynapseError( + 502, + "Unable to join because the remote server passed back a state DAG that includes rejected events (invalid)", + ) # and now persist the join event itself. logger.info( @@ -2023,7 +2029,9 @@ async def process(event: EventBase) -> EventPersistencePair: event, calculated_auth_events.values() ) except AuthError as e: - logger.warning("Rejecting %r while persisting state DAG because %s", event, e) + logger.warning( + "Rejecting %r while persisting state DAG because %s", event, e + ) context.rejected = RejectedReason.AUTH_ERROR except EventSizeError as e: if e.unpersistable: From b6b1a589e9524ed4c11aaa4378538a89227d2890 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:46:15 +0100 Subject: [PATCH 10/62] Review comments --- synapse/handlers/federation_event.py | 31 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 9f3615e482c..ff6b126e1ed 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1999,7 +1999,6 @@ async def process(event: EventBase) -> EventPersistencePair: for event_id in itertools.chain( calculated_auth_event_ids, event.prev_state_events ) - if event_id in event_map } # If we have calculated state before this event, remember it so we don't need # to keep asking the database. The state we are remembering is StateMap[event_id] @@ -2117,6 +2116,7 @@ async def _calculate_state_dag_context( if known_prev_state_maps: if len(known_prev_state_maps) == 1: + # There's only 1 state map so we don't need to do state resolution at all. state_ids = list(known_prev_state_maps.values())[0] else: res = await self._state_resolution_handler.resolve_state_groups( @@ -2133,25 +2133,26 @@ async def _calculate_state_dag_context( state_ids = await self._state_handler.compute_state_after_events( event.room_id, event.prev_state_events, - # There is no filter to apply here + # There is no filter to apply here and we want all the events anyway since we need + # to persist the entire calculate state. state_filter=None, - # we need to have finished processing the entire state DAG before we can - # compute the state for new events, but this function is called WHEN we are processing - # the entire state DAG. To wait here would be circular, we'd deadlock. + # we need to have finished processing the events causally prior to `event` in the + # state DAG before we can compute the state for this `event`, but this function is + # called unilaterally WHEN we are processing events in the state DAG. + # To wait here would be circular, we'd deadlock. await_full_state=False, ) # We should always have some resolved state after the prev_state_events, except # for the create event which is the start of the state DAG. - is_create_event = ( - len(event.prev_state_events) == 0 - and event.type == EventTypes.Create - and event.get_state_key() == "" - ) + is_create_event = len(event.prev_state_events) == 0 and ( + event.type, + event.get_state_key(), + ) == (EventTypes.Create, "") if not is_create_event: # We must have calculated room state for every event by now. # Only the create event can have no room state (since it's the first event in the room) - assert len(state_ids) > 0 + assert len(state_ids) > 0, "State at prev_state_events was not calculated" context = await self._state_handler.compute_event_context( event, @@ -2561,9 +2562,6 @@ async def _load_calculated_auth_events( events locally. The calculated auth event IDs are remembered on the event so we don't need to calculate them again when the event is persisted. - Rejected events never form part of the room state, so the calculated auth events - cannot be rejected and are loaded without allowing rejected events. - Args: event: the event whose auth events we want. context: the state before the event. @@ -2577,7 +2575,10 @@ async def _load_calculated_auth_events( ) event.internal_metadata.calculated_auth_event_ids = calculated_auth_event_ids calculated_auth_events = await self._store.get_events( - calculated_auth_event_ids, allow_rejected=False + calculated_auth_event_ids, + # Rejected events never form part of the room state, so the calculated auth events + # cannot be rejected and are loaded without allowing rejected events. + allow_rejected=False, ) return calculated_auth_events.values() From 4b3a72b12615dc5d2f95152de4debc4a77e9a155 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:12:00 +0100 Subject: [PATCH 11/62] Review comments --- synapse/handlers/federation_event.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index ff6b126e1ed..76d1a15e826 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1973,7 +1973,6 @@ async def process(event: EventBase) -> EventPersistencePair: calculated_auth_events = { event_id: event_map[event_id] for event_id in calculated_auth_event_ids - if event_id in event_map } # In theory we should not be missing any auth events because event_map contains # the entire state DAG, but if we do we can ask the database if it knows about them. @@ -2111,7 +2110,11 @@ async def _calculate_state_dag_context( SynapseError if the event has no `prev_state_events` and is not a create event. """ - if len(event.prev_state_events) == 0 and event.type != EventTypes.Create: + is_create_event = len(event.prev_state_events) == 0 and ( + event.type, + event.get_state_key(), + ) == (EventTypes.Create, "") + if len(event.prev_state_events) == 0 and not is_create_event: raise SynapseError(502, f"event {event.event_id} has no prev_state_events") if known_prev_state_maps: @@ -2145,10 +2148,7 @@ async def _calculate_state_dag_context( # We should always have some resolved state after the prev_state_events, except # for the create event which is the start of the state DAG. - is_create_event = len(event.prev_state_events) == 0 and ( - event.type, - event.get_state_key(), - ) == (EventTypes.Create, "") + if not is_create_event: # We must have calculated room state for every event by now. # Only the create event can have no room state (since it's the first event in the room) From 78dd966f7038c6a8a75f9f0929eac006d4b68a80 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:14:17 +0100 Subject: [PATCH 12/62] Update synapse/handlers/federation_event.py Co-authored-by: Eric Eastwood --- synapse/handlers/federation_event.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 76d1a15e826..544f6a7e14c 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -2143,6 +2143,19 @@ async def _calculate_state_dag_context( # state DAG before we can compute the state for this `event`, but this function is # called unilaterally WHEN we are processing events in the state DAG. # To wait here would be circular, we'd deadlock. + # By this point, we expect to have already finished processing the + # events *causally prior* to `event` in the state DAG because this + # function is called unilaterally WHEN we are processing events in the + # state DAG all the way down the chain and the caller ensures we process + # events in topological order. + # + # If this assumption is ever violated, `compute_state_after_events` will + # yell loudly about missing state groups. + # + # In the context of partially stated rooms (faster remote room joins) + # this would be part of background step verifying the entire state DAG, + # so we know that we don't have the full state processed yet. To wait + # here would be circular, we'd deadlock. await_full_state=False, ) From 87e68d14f25394530912c7920ebb1cd00ccb1ec1 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:37:47 +0100 Subject: [PATCH 13/62] Update synapse/handlers/federation_event.py Co-authored-by: Eric Eastwood --- synapse/handlers/federation_event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 544f6a7e14c..1e0f381f550 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -588,7 +588,7 @@ async def process_remote_join( # The state DAG must not include rejected events raise SynapseError( 502, - "Unable to join because the remote server passed back a state DAG that includes rejected events (invalid)", + "Unable to join because the remote server passed back a state DAG that includes rejected events (invalid)", ) # and now persist the join event itself. From f99f0472c1baa3112a8330bf124a23808380f987 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:42:49 +0100 Subject: [PATCH 14/62] Review comments --- synapse/handlers/federation_event.py | 20 ++++++++++++++------ synapse/storage/databases/main/rejections.py | 13 +++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 1e0f381f550..c5ce65c538d 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1768,9 +1768,14 @@ async def _auth_and_persist_outliers( # # This is just an optimisation, so it doesn't need to be watertight - the event # persister does another round of deduplication. + has_rejected_events = False seen_remotes = await self._store.have_seen_events(room_id, event_map.keys()) - for s in seen_remotes: - event_map.pop(s, None) + if seen_remotes: + for s in seen_remotes: + event_map.pop(s, None) + rejected_event_ids = self._store.get_rejected_events(seen_remotes) + if rejected_event_ids: + has_rejected_events = True # XXX: it might be possible to kick this process off in parallel with fetching # the events. @@ -1875,10 +1880,12 @@ async def prep(event: EventBase) -> None: if (i + 1) % 1000 == 0: await self._clock.sleep(Duration(seconds=0)) - has_rejected_events = any( - context.rejected is not None - for _, context in events_and_contexts_to_persist - ) + if not has_rejected_events: + # Check if any of the persisted events were rejected + has_rejected_events = any( + context.rejected is not None + for _, context in events_and_contexts_to_persist + ) # Also persist the new event in batches for similar reasons as above. for batch in batch_iter(events_and_contexts_to_persist, 1000): @@ -2120,6 +2127,7 @@ async def _calculate_state_dag_context( if known_prev_state_maps: if len(known_prev_state_maps) == 1: # There's only 1 state map so we don't need to do state resolution at all. + # resolve_state_groups should not be called for a single state group as per its docstring state_ids = list(known_prev_state_maps.values())[0] else: res = await self._state_resolution_handler.resolve_state_groups( diff --git a/synapse/storage/databases/main/rejections.py b/synapse/storage/databases/main/rejections.py index c73c3d761dd..9ea91a1edc1 100644 --- a/synapse/storage/databases/main/rejections.py +++ b/synapse/storage/databases/main/rejections.py @@ -20,6 +20,7 @@ # import logging +from typing import Set from synapse.storage._base import SQLBaseStore @@ -35,3 +36,15 @@ async def get_rejection_reason(self, event_id: str) -> str | None: allow_none=True, desc="get_rejection_reason", ) + + async def get_rejected_events(self, event_ids: Set[str]) -> Set[str]: + """Filter the provided event IDs to only return rejected events.""" + rows = await self.db_pool.simple_select_many_batch( + table="rejections", + column="event_id", + iterable=event_ids, + retcols=("event_id",), + keyvalues={}, + desc="get_rejected_events", + ) + return {r[0] for r in rows} From a835dac09095bd0ff60ee16a4b02385cd7c8f87d Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:44:06 +0100 Subject: [PATCH 15/62] Linting --- synapse/storage/databases/main/rejections.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/synapse/storage/databases/main/rejections.py b/synapse/storage/databases/main/rejections.py index 9ea91a1edc1..389d0643a01 100644 --- a/synapse/storage/databases/main/rejections.py +++ b/synapse/storage/databases/main/rejections.py @@ -20,7 +20,6 @@ # import logging -from typing import Set from synapse.storage._base import SQLBaseStore @@ -37,7 +36,7 @@ async def get_rejection_reason(self, event_id: str) -> str | None: desc="get_rejection_reason", ) - async def get_rejected_events(self, event_ids: Set[str]) -> Set[str]: + async def get_rejected_events(self, event_ids: set[str]) -> set[str]: """Filter the provided event IDs to only return rejected events.""" rows = await self.db_pool.simple_select_many_batch( table="rejections", From c1087960ccab393e52115246d54bf91b80c12fea Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:26:47 +0100 Subject: [PATCH 16/62] Factor out _get_events_from_remote from _get_events_and_persist --- synapse/handlers/federation_event.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index c5ce65c538d..79458f16cf2 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1672,10 +1672,10 @@ async def backfill_event_id( @trace @tag_args - async def _get_events_and_persist( + async def _get_events_from_remote( self, destination: str, room_id: str, event_ids: StrCollection - ) -> None: - """Fetch the given events from a server, and persist them as outliers. + ) -> list[EventBase]: + """Fetch the given events from a server. This function *does not* recursively get missing auth events of the newly fetched events. Callers must include in the `event_ids` argument @@ -1714,6 +1714,20 @@ async def get_event(event_id: str) -> None: ) await concurrently_execute(get_event, event_ids, 5) + return events + + async def _get_events_and_persist( + self, destination: str, room_id: str, event_ids: StrCollection + ) -> None: + """Fetch the given events from a server, and persist them as outliers. + + This function *does not* recursively get missing auth events of the + newly fetched events. Callers must include in the `event_ids` argument + any missing events from the auth chain. + + Logs a warning if we can't find the given event. + """ + events = await self._get_events_from_remote(destination, room_id, event_ids) logger.info("Fetched %i events of %i requested", len(events), len(event_ids)) await self._auth_and_persist_outliers(room_id, events) From 658523546c1411a5cf3a835d81bc2a2631bc67e9 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:39:48 +0100 Subject: [PATCH 17/62] Fetch missing state DAG events via /get_missing_events --- synapse/handlers/federation_event.py | 233 ++++++++++++++++++++ tests/handlers/test_federation_event.py | 278 ++++++++++++++++++++++++ 2 files changed, 511 insertions(+) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 79458f16cf2..a8b2c9b333e 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1731,6 +1731,239 @@ async def _get_events_and_persist( logger.info("Fetched %i events of %i requested", len(events), len(event_ids)) await self._auth_and_persist_outliers(room_id, events) + async def _fetch_missing_state_dag_events( + self, + destination: str, + event: MSC4242Event, + ) -> Iterable[EventBase]: + """If we are missing some of an event's prev state events, request them until we + fill in the complete state DAG. + + Args: + destination: where to fetch the state dag from + event: The event we're missing prev_state_events for. + Returns: + The missed state DAG events. + """ + # The logic for this is gnarly but the general idea is to hit /get_missing_events + # with the event ID (hence known as the "back set") to walk back up the state DAG + # breadth first. We then need to see if we have seen any of these events, in which + # case they can be removed from the back set. + # When the back set size reaches 0, we have filled in the entire DAG. It's gnarly + # because we don't have a clear idea when we have filled in the state DAG without + # checking with the database for potentially a lot of events. Consider the graph: + # .-- E <- F <- G + # A <- B <- C <- D + # `-- H <- I <- J + # Assume this server knows A-G and receives J. Knowing the forwards extremities in + # the room (G) does nothing to help us know when we have connected up the state DAG + # at D. Thus, this function queries the database for all returned events to see if + # we have seen them, and then filters them out. + # + # This function terminates when either: + # - the back set size is 0 (we filled in the gap) + # - the back set entries do not change after a round of /get_missing_events + # (we're not making forward progress), in which case this indicates the remote + # server is lying to us and not sending us events we need. + # + # There are tradeoffs here between # round trips, amount of memory consumed and # DB + # hits. We could reduce memory consumed by persisting intermediate events in a + # staging area on disk. We could reduce DB hits by only querying a subset of events + # (and using the topological ordering) which may mean we try to process events we've + # already seen. We try to reduce the # round trips by exponentially increasing the + # limit in each request. Better algorithms exist here (search for "set + # reconciliation") but as of today, we don't do any of them. + + # this function is expensive. See if we need to do it at all. + seen = await self._store.have_seen_events( + event.room_id, event.prev_state_events + ) + if seen == set(event.prev_state_events): + return [] + + room_id = event.room_id + # we allow this amount of time for each event we're going to receive. + # This dynamically adjusts the timeout to account for very large responses. + timeout_ms_per_event = 100 + iteration = 0 + limit = 8 + # we maintain 3 sets: the back set is what the next /gme request will be, and the + # /gme response events get bucketed into one of these 3 (seen, missed, back) sets. + missed_events: dict[str, MSC4242Event] = {} + seen_event_ids: set[str] = set() + # we operate on state events, but we may have originally hit the backwards + # extremity with a message event. If we do, we need to grab the prev_state_events + # first to seed the back set. /get_missing_events will not return the event we + # provide to it in latest_events. + back_set: dict[str, MSC4242Event] = {} + if event.is_state(): + back_set = {event.event_id: event} + else: + prev_state_events = await self._get_events_from_remote( + destination, room_id, event.prev_state_events + ) + back_set = { + e.event_id: e + for e in prev_state_events + if e.event_id not in seen + and e.room_id == room_id + and supports_msc4242_state_dag(e) + } + # the back set now consists of state events we have not seen, so ensure we + # return them to the caller + missed_events = {e.event_id: e for e in back_set.values()} + + while len(back_set) > 0: + logger.info( + "missed=%s seen=%s back_set=%s", + missed_events.keys(), + seen_event_ids, + back_set.keys(), + ) + # remember which events we're querying for. If we don't make forward progress + # we'll bail. + before_back_set = set(back_set) + max_events_per_req = limit * pow(2, iteration) # 8x1, 8x2, 8x4, ... + try: + # 10s base then +(100ms x # events) on top e.g 64 events = +6400ms = 16.4s + timeout = 10000 + (max_events_per_req * timeout_ms_per_event) + remote_events = await self._federation_client.get_missing_events( + destination, + room_id, + earliest_events_ids=[], + latest_events=list(back_set.values()), + limit=max_events_per_req, + min_depth=0, + timeout=timeout, + state_dag=True, + ) + # the remote server is untrusted, so discard anything that isn't a state + # DAG event in the room we asked about. + remote_events_map = { + ev.event_id: ev + for ev in remote_events + if ev.room_id == room_id and supports_msc4242_state_dag(ev) + } + except ( + RequestSendFailed, + HttpResponseException, + NotRetryingDestination, + ) as e1: + logger.warning( + "Failed to get missing state dag events from remote: %s", e1 + ) + # by returning nothing we all but guarantee that the processing of the + # event received over federation will fail. We'll try doing this again the + # next time this server sends an event to us. + # TODO(kegan): Having a staging area of auth events we have got but not yet + # authed would help us stop doing repeat work. + return [] + + # bucket the remote events into seen / unseen. We include each event's + # prev_state_events here because that way we might be able to skip another + # request i.e we know we have seen the prev_state_events so don't bother + # fetching them again. + remote_event_ids = { + event_id + for ev in remote_events_map.values() + for event_id in ev.prev_state_events + } + remote_event_ids.update(remote_events_map.keys()) + # no need to ask the database about events we already know we've seen in a + # previous iteration. + remote_event_ids.difference_update(seen_event_ids) + seen_remotes = await self._store.have_seen_events( + room_id, + remote_event_ids, + ) + seen_event_ids.update(seen_remotes) + unseen_remotes = set(remote_events_map).difference(seen_event_ids) + + # all unseen events must be returned + missed_events.update( + {k: v for (k, v) in remote_events_map.items() if k in unseen_remotes} + ) + + # now figure out what the new back set is. In the common case, remote events + # will have a long chain of new events e.g A <- B <- C <- D so we want to walk + # up this graph if all the events are unseen. If there are seen events (e.g A) + # then when we reach A we terminate that branch as we have filled in the gap. + # In order to avoid mutating the dict whilst iterating, we iterate over the back + # set snapshot we took earlier, and try to exhaust it (i.e it maps an event ID + # to a new earlier event ID(s) or None if we filled in the gap.) + back_queue = collections.deque(before_back_set) + new_back_set: set[str] = set() + while back_queue: + # If the prevs are: + # - All seen: we've filled in the gap, don't add this event to the back set. + # - All fetched: add all the prevs to the back set. + # - Mixed seen/fetched: add all the fetched prevs to the back set + # - Any unseen: keep this event in the back set. + back_event_id = back_queue.popleft() + back_event = missed_events.get( + back_event_id, back_set.get(back_event_id) + ) + if back_event is None: + continue + seen_all_prevs = all( + pae in seen_event_ids for pae in back_event.prev_state_events + ) + if seen_all_prevs: + continue + has_any_unseen_prev = any( + pae not in seen_event_ids and pae not in missed_events + for pae in back_event.prev_state_events + ) + if has_any_unseen_prev: + new_back_set.add(back_event_id) + continue + + # if we reach here then we have a mixture of seen/fetched prevs. Add the + # fetched prevs to the queue + back_queue.extend( + pae + for pae in back_event.prev_state_events + if pae not in seen_event_ids + ) + + # if there is an event with lots of prev_state_events, so many that our limit + # won't pull them all in, then we have a problem if we give up trying to walk + # backwards. Ensure the limit is at least that large before giving up. + max_prev_state_events_on_single_event = max( + len(ev.prev_state_events) for ev in back_set.values() + ) + if ( + new_back_set == before_back_set + and max_events_per_req > max_prev_state_events_on_single_event + ): + # we didn't make forward progress, give up. + logger.warning( + "Failed to make forward progress when walking back through state dag, " + "stuck at back set %s", + before_back_set, + ) + return [] + iteration += 1 # let the limit exponentially increase + + # edge case: the initial event we put as latest_events has so many + # prev_state_events that we did not make forward progress yet. In this case, + # missed_events does NOT have the initial event, as it was not returned from + # /get_missing_events. Therefore, we just special case this scenario and set the + # back set accordingly. + if new_back_set == {event.event_id}: + back_set = {event.event_id: event} + else: + back_set = { + event_id: missed_events[event_id] for event_id in new_back_set + } + + logger.info( + "fetch_missing_state_dag_events returning %s events from %s", + len(missed_events), + event.event_id, + ) + return missed_events.values() + @trace async def _auth_and_persist_outliers( self, diff --git a/tests/handlers/test_federation_event.py b/tests/handlers/test_federation_event.py index 0e15c8d7447..6a8a5ca1bdd 100644 --- a/tests/handlers/test_federation_event.py +++ b/tests/handlers/test_federation_event.py @@ -18,6 +18,7 @@ # [This file includes modifications made by New Vector Limited] # # +from typing import Iterable from unittest import mock from twisted.internet.testing import MemoryReactor @@ -28,6 +29,7 @@ check_state_dependent_auth_rules, check_state_independent_auth_rules, ) +from synapse.events import EventBase from synapse.events.py_protocol import MSC4242Event, supports_msc4242_state_dag from synapse.events.snapshot import EventContext from synapse.federation.transport.client import StateRequestResponse @@ -1240,3 +1242,279 @@ def test_missing_intermediate_event(self) -> None: third = self._make_event([second.event_id]) self.assertFalse(is_state_dag_connected([create, third])) + + +STATE_DAG_ROOM_ID = "!test:test" + + +class FetchMissingStateDagEventsTests(unittest.FederatingHomeserverTestCase): + """Tests for `_fetch_missing_state_dag_events`, which walks back an MSC4242 state + DAG over federation via /get_missing_events until every prev state event is filled + in. + + The federation client and the store's `have_seen_events` are mocked so the walk-back + algorithm can be driven against synthetic graphs without any real networking or + persistence. + """ + + def setUp(self) -> None: + super().setUp() + self._origin_server_ts = 0 + + def _make_event(self, prev_state_events: list[str]) -> MSC4242Event: + """Build an MSC4242 state event with the given `prev_state_events`.""" + # Event IDs are hashes of the event, so vary a field to keep them distinct. + self._origin_server_ts += 1 + event = make_test_event( + { + "type": "m.room.topic", + "state_key": "", + "content": {}, + "sender": "@alice:test", + "origin_server_ts": self._origin_server_ts, + "room_id": STATE_DAG_ROOM_ID, + "prev_state_events": prev_state_events, + }, + room_version=RoomVersions.MSC4242v12, + ) + assert supports_msc4242_state_dag(event) + return event + + def _make_state_dag(self, graph: dict[str, list[str]]) -> dict[str, MSC4242Event]: + """Build a state DAG from a graph of fake ID -> fake prev_state_event IDs. + + Returns a map of fake ID to the real event. The graph must be listed + topologically (parents before children) else a lookup will fail. + """ + fake_to_real_id: dict[str, str] = {} + result: dict[str, MSC4242Event] = {} + for fake_id, fake_prev_state_events in graph.items(): + prevs = [fake_to_real_id[fpae] for fpae in fake_prev_state_events] + event = self._make_event(prevs) + fake_to_real_id[fake_id] = event.event_id + result[fake_id] = event + return result + + def _prepare_handler( + self, + seen_fake_events: set[str], + graph: dict[str, MSC4242Event], + gme_req_resps: dict[tuple[str, ...], list[str]], + ) -> None: + """Mock the federation client and store to serve the given state DAG. + + Args: + seen_fake_events: fake IDs of events this homeserver already has. + graph: the state DAG, mapping fake ID to the real event. + gme_req_resps: the /get_missing_events responses to return. Keys are the + fake IDs passed as `latest_events`; values are the fake IDs to return. + """ + handler = self.hs.get_federation_event_handler() + + async def get_missing_events( + destination: str, + room_id: str, + earliest_events_ids: Iterable[str], + latest_events: Iterable[EventBase], + limit: int, + min_depth: int, + timeout: int, + state_dag: bool = False, + ) -> list[EventBase]: + assert state_dag + assert room_id == STATE_DAG_ROOM_ID + target_key = sorted(ev.event_id for ev in latest_events) + assert target_key + for req, resp in gme_req_resps.items(): + if sorted(graph[x].event_id for x in req) == target_key: + return [graph[x] for x in resp] + raise AssertionError( + f"get_missing_events with latest={target_key} but no matching " + f"response found (tested {len(gme_req_resps)})" + ) + + handler._federation_client.get_missing_events = mock.AsyncMock( # type: ignore[method-assign] + side_effect=get_missing_events + ) + + seen_event_ids = {graph[fake_id].event_id for fake_id in seen_fake_events} + + async def have_seen_events(room_id: str, event_ids: Iterable[str]) -> set[str]: + return seen_event_ids.intersection(event_ids) + + store = self.hs.get_datastores().main + store.have_seen_events = mock.AsyncMock(side_effect=have_seen_events) # type: ignore[method-assign] + + def _assert_fetches( + self, + graph: dict[str, MSC4242Event], + start_fake_id: str, + want_fake_ids: list[str], + ) -> None: + """Fetch the missing DAG for `start_fake_id` and assert we got `want_fake_ids`.""" + got = self.get_success( + self.hs.get_federation_event_handler()._fetch_missing_state_dag_events( + "unknown", graph[start_fake_id] + ) + ) + self.assertEqual( + {ev.event_id for ev in got}, + {graph[x].event_id for x in want_fake_ids}, + ) + + def test_linear(self) -> None: + linear = self._make_state_dag( + { + "A": [], + "B": ["A"], + "C": ["B"], + } + ) + self._prepare_handler(set(), linear, {("C",): ["A", "B"]}) + self._assert_fetches(linear, "C", ["A", "B"]) + + def test_linear_seen(self) -> None: + linear = self._make_state_dag( + { + "A": [], + "B": ["A"], + "C": ["B"], + "D": ["C"], + } + ) + self._prepare_handler( + {"A", "B"}, + linear, + { + ("D",): ["C"], + ("C",): ["B"], + }, + ) + self._assert_fetches(linear, "D", ["C"]) + + def test_fork_merge(self) -> None: + fork_merge = self._make_state_dag( + { + "A": [], + "B": ["A"], + "C": ["A"], + "D": ["C"], + "E": ["B"], + "F": ["D", "E"], + } + ) + self._prepare_handler( + set(), + fork_merge, + { + ("F",): ["D", "E"], + ("D", "E"): ["C", "A"], + ("E",): ["B", "A"], + }, + ) + self._assert_fetches(fork_merge, "F", ["A", "B", "C", "D", "E"]) + + def test_give_up_no_forward_progress(self) -> None: + fork_merge = self._make_state_dag( + { + "A": [], + "B": ["A"], + "C": ["A"], + "D": ["C", "B"], + } + ) + # never provide B, so we can't connect the DAG and must give up. + self._prepare_handler(set(), fork_merge, {("D",): ["C"]}) + self._assert_fetches(fork_merge, "D", []) + + def test_seen(self) -> None: + fork_merge = self._make_state_dag( + { + "A": [], + "B": ["A"], + "C": ["A"], + "D": ["C"], + "E": ["B"], + "F": ["D", "E"], + } + ) + self._prepare_handler( + {"A", "B"}, + fork_merge, + { + ("F",): ["D", "E"], + # NB: not D,E as we've seen E's prev_state_events => B. + ("D",): ["C", "A"], + ("E",): ["B", "A"], + }, + ) + self._assert_fetches(fork_merge, "F", ["C", "D", "E"]) + + def test_memoise(self) -> None: + # There are two paths to A: via D and via EFGH. Both converge at C and then go to + # A. The first path to reach C should be remembered so we don't request it again. + # A <- B <- C <- D <------------------------ I + # `----- E <-- F <-- G <-- H <--` + memoise = self._make_state_dag( + { + "A": [], + "B": ["A"], + "C": ["B"], + "D": ["C"], + "E": ["C"], + "F": ["E"], + "G": ["F"], + "H": ["G"], + "I": ["D", "H"], + } + ) + self._prepare_handler( + set(), + memoise, + { + ("I",): ["D", "H"], + ("D", "H"): ["C", "G"], + ("C", "G"): ["B", "F"], + ("B", "F"): ["A", "E"], + # we should never request C on its own as we remember we visited it already. + }, + ) + self._assert_fetches(memoise, "I", ["A", "B", "C", "D", "E", "F", "G", "H"]) + + def test_seen_all(self) -> None: + seen_all = self._make_state_dag( + { + "A": [], + "B": ["A"], + "C": ["A"], + "D": ["C"], + } + ) + self._prepare_handler({"A", "B", "C", "D"}, seen_all, {}) + self._assert_fetches(seen_all, "D", []) + + def test_ignores_events_from_other_rooms(self) -> None: + linear = self._make_state_dag( + { + "A": [], + "B": ["A"], + "C": ["B"], + } + ) + # a malicious remote slips an event from an unrelated room into the response. + foreign = make_test_event( + { + "type": "m.room.topic", + "state_key": "", + "content": {}, + "sender": "@evil:test", + "origin_server_ts": 1000, + "room_id": "!other:test", + "prev_state_events": [], + }, + room_version=RoomVersions.MSC4242v12, + ) + assert supports_msc4242_state_dag(foreign) + linear["FOREIGN"] = foreign + self._prepare_handler(set(), linear, {("C",): ["A", "B", "FOREIGN"]}) + self._assert_fetches(linear, "C", ["A", "B"]) From aa3cca5297a4034a69d322c61b0c958a94da46f7 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:17:56 +0100 Subject: [PATCH 18/62] Compute event context for pulled state DAG events --- synapse/handlers/federation_event.py | 41 +++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index a8b2c9b333e..17dd655ea69 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1167,6 +1167,14 @@ async def _compute_event_context_with_maybe_missing_prevs( FederationError if we fail to get the state from the remote server after any missing `prev_event`s. """ + if supports_msc4242_state_dag(event): + # MSC4242 State DAG rooms carry their state as the event's `prev_state_events` + # rather than in the timeline, so we fill in the state DAG rather than asking + # the remote server for the state after each missing `prev_event`. + return await self._compute_event_context_with_maybe_missing_prevs_state_dag( + dest, event + ) + room_id = event.room_id event_id = event.event_id @@ -1280,6 +1288,35 @@ async def _compute_event_context_with_maybe_missing_prevs( event, state_ids_before_event=state_map, partial_state=partial_state ) + async def _compute_event_context_with_maybe_missing_prevs_state_dag( + self, dest: str, event: MSC4242Event + ) -> EventContext: + """Build an EventContext for a pulled MSC4242 State DAG event whose + `prev_state_events` may be missing. + + Unlike the timeline-based path, we don't trust the remote server to tell us the + state at the event. Instead we fill in the event's state DAG by fetching any + missing `prev_state_events`, persist them as outliers, and then calculate the + state (and hence the auth events) before the event ourselves. The calculated auth + event IDs are remembered on the event so we don't recompute them when persisting. + + Params: + dest: the remote server to fetch missing state DAG events from. Typically the + server we got `event` from. + event: the pulled event to compute a context for. + + Returns: + The event context. + """ + missed_events = await self._fetch_missing_state_dag_events(dest, event) + await self._auth_and_persist_outliers(event.room_id, missed_events) + ( + context, + calculated_auth_event_ids, + ) = await self._calculate_state_dag_context(event) + event.internal_metadata.calculated_auth_event_ids = calculated_auth_event_ids + return context + @trace @tag_args async def _get_state_ids_after_missing_prev_event( @@ -3078,7 +3115,9 @@ def _sanity_check_event(self, ev: EventBase) -> None: ) raise SynapseError(HTTPStatus.BAD_REQUEST, "Too many prev_events") - if len(ev.auth_event_ids()) > 10: + # MSC4242 State DAG events don't list their auth events (they're calculated from + # the state DAG), so there's nothing to bound here for them. + if not supports_msc4242_state_dag(ev) and len(ev.auth_event_ids()) > 10: logger.warning( "Rejecting event %s which has %i auth_events", ev.event_id, From 6908536904651594abaa365b029153d4b102bc77 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:36:23 +0100 Subject: [PATCH 19/62] Fetch missing prev_state events via the state DAG walk --- synapse/handlers/federation_event.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 17dd655ea69..15d1dc5e5c7 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -2827,9 +2827,17 @@ async def _load_or_fetch_auth_events_for_event( missing_auth_event_ids, ) try: - await self._get_remote_auth_chain_for_event( - destination, event.room_id, event.event_id - ) + if supports_msc4242_state_dag(event): + # MSC4242 events don't list auth events, so the "missing auth events" are + # really missing `prev_state_events`: walk the state DAG to fill them in. + missed_events = await self._fetch_missing_state_dag_events( + destination, event + ) + await self._auth_and_persist_outliers(event.room_id, missed_events) + else: + await self._get_remote_auth_chain_for_event( + destination, event.room_id, event.event_id + ) except Exception as e: logger.warning("Failed to get auth chain for %s: %s", event, e) # in this case, it's very likely we still won't have all the auth From a6c77966863128765127b4d6301284e044088b0b Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:45:29 +0100 Subject: [PATCH 20/62] Soft-fail pulled state DAG events against DAG extremities --- synapse/handlers/federation_event.py | 82 ++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 23 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 15d1dc5e5c7..ea983dfa83b 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -2670,8 +2670,17 @@ async def _check_for_soft_fail( # partial and full state and may not be accurate. return - extrem_ids = await self._store.get_latest_event_ids_in_room(event.room_id) - prev_event_ids = set(event.prev_event_ids()) + if supports_msc4242_state_dag(event): + # In MSC4242 State DAG rooms the "current state" lives in the state DAG rather + # than the timeline, so compare the state DAG extremities against the event's + # `prev_state_events`. + extrem_ids: frozenset[str] = await self._store.get_state_dag_extremities( + event.room_id + ) + prev_event_ids = set(event.prev_state_events) + else: + extrem_ids = await self._store.get_latest_event_ids_in_room(event.room_id) + prev_event_ids = set(event.prev_event_ids()) if extrem_ids == prev_event_ids: # If they're the same then the current state is the same as the @@ -2685,23 +2694,13 @@ async def _check_for_soft_fail( auth_types = auth_types_for_event(room_version_obj, event) # Calculate the "current state". - seen_event_ids = await self._store.have_events_in_timeline(prev_event_ids) - has_missing_prevs = bool(prev_event_ids - seen_event_ids) - if has_missing_prevs: - # We don't have all the prev_events of this event, which means we have a - # gap in the graph, and the new event is going to become a new backwards - # extremity. - # - # In this case we want to be a little careful as we might have been - # down for a while and have an incorrect view of the current state, - # however we still want to do checks as gaps are easy to - # maliciously manufacture. - # - # So we use a "current state" that is actually a state - # resolution across the current forward extremities and the - # given state at the event. This should correctly handle cases - # like bans, especially with state res v2. - + if supports_msc4242_state_dag(event): + # Because we may have just come back online after a long time, we don't know + # which is newer: our state DAG extremities or the event's state. As such, we + # state resolve across those state sets to try to ensure we are seeing the + # 'current' state, particularly for catching bans. This is the same reasoning + # as the `has_missing_prevs` branch below, but in a state DAG world we always + # have the event's `prev_state_events` so we always do it. state_sets_d = await self._state_storage_controller.get_state_groups_ids( event.room_id, extrem_ids ) @@ -2720,11 +2719,48 @@ async def _check_for_soft_fail( ) ) else: - current_state_ids = ( - await self._state_storage_controller.get_current_state_ids( - event.room_id, StateFilter.from_types(auth_types) + seen_event_ids = await self._store.have_events_in_timeline(prev_event_ids) + has_missing_prevs = bool(prev_event_ids - seen_event_ids) + if has_missing_prevs: + # We don't have all the prev_events of this event, which means we have a + # gap in the graph, and the new event is going to become a new backwards + # extremity. + # + # In this case we want to be a little careful as we might have been + # down for a while and have an incorrect view of the current state, + # however we still want to do checks as gaps are easy to + # maliciously manufacture. + # + # So we use a "current state" that is actually a state + # resolution across the current forward extremities and the + # given state at the event. This should correctly handle cases + # like bans, especially with state res v2. + + state_sets_d = ( + await self._state_storage_controller.get_state_groups_ids( + event.room_id, extrem_ids + ) + ) + state_sets = list(state_sets_d.values()) + state_ids = await context.get_prev_state_ids() + state_sets.append(state_ids) + current_state_ids = ( + await self._state_resolution_handler.resolve_events_with_store( + event.room_id, + room_version, + state_sets, + event_map=None, + state_res_store=StateResolutionStore( + self._store, self._state_deletion_store + ), + ) + ) + else: + current_state_ids = ( + await self._state_storage_controller.get_current_state_ids( + event.room_id, StateFilter.from_types(auth_types) + ) ) - ) logger.debug( "Doing soft-fail check for %s: state %s", From 2d0d65e1d4f7836d4b9f10d3b3f034f5406e660a Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:26:28 +0100 Subject: [PATCH 21/62] Test receiving MSC4242 state DAG events over federation --- .../test_federation_pull_state_dag.py | 386 ++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 tests/federation/test_federation_pull_state_dag.py diff --git a/tests/federation/test_federation_pull_state_dag.py b/tests/federation/test_federation_pull_state_dag.py new file mode 100644 index 00000000000..dbb9eae5310 --- /dev/null +++ b/tests/federation/test_federation_pull_state_dag.py @@ -0,0 +1,386 @@ +# +# 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: +# . +# + +import logging +import urllib.parse +from http import HTTPStatus +from typing import Callable, TypeVar +from unittest.mock import Mock + +from twisted.internet.testing import MemoryReactor + +from synapse.api.constants import ( + EventContentFields, + EventTypes, + JoinRules, + Membership, +) +from synapse.api.room_versions import RoomVersions +from synapse.events import EventBase +from synapse.federation.transport.client import SendJoinResponse +from synapse.http.matrixfederationclient import ByteParser +from synapse.http.types import QueryParams +from synapse.rest import admin +from synapse.rest.client import login, room +from synapse.server import HomeServer +from synapse.types import JsonDict +from synapse.util.clock import Clock + +from tests import unittest +from tests.test_utils.event_builders import make_test_event + +logger = logging.getLogger(__name__) + +ROOM_VERSION = RoomVersions.MSC4242v12 + +T = TypeVar("T") + + +class FederationPullStateDagTestCase(unittest.FederatingHomeserverTestCase): + """Tests for receiving pulled events in a remote MSC4242 State DAG room. + + A room is first joined over federation (the same way as the join tests), which leaves + a real, fully-persisted state DAG on the local homeserver. Events are then fed through + the real inbound pull path (`_process_pulled_event`), with the remote server's + /get_missing_events responses supplied by the test (by mocking the federation HTTP + client). This exercises the state DAG walk, outlier persistence, state/auth + calculation and the soft-fail check for real. + """ + + servlets = [ + admin.register_servlets, + login.register_servlets, + room.register_servlets, + ] + + def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer: + self._federation_http_client = Mock() + return self.setup_test_homeserver( + federation_http_client=self._federation_http_client + ) + + def default_config(self) -> JsonDict: + config = super().default_config() + config["experimental_features"] = {"msc4242_enabled": True} + return config + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + super().prepare(reactor, clock, hs) + + self.store = self.hs.get_datastores().main + self.state_storage_controller = hs.get_storage_controllers().state + self.local_user_id = self.register_user("user1", "pass") + self.local_user_tok = self.login("user1", "pass") + self.remote_creator_user_id = f"@remote-creator:{self.OTHER_SERVER_NAME}" + self._depth = 0 + + def _remote_event( + self, + room_id: str | None, + event_type: str, + state_key: str, + content: JsonDict, + prev_state_events: list[str], + sender: str | None = None, + ) -> EventBase: + """Build a state event signed by the remote server.""" + self._depth += 1 + event_dict: JsonDict = { + "type": event_type, + "state_key": state_key, + "content": content, + "sender": sender or self.remote_creator_user_id, + "depth": self._depth, + "origin_server_ts": self._depth, + "prev_state_events": prev_state_events, + "prev_events": prev_state_events, + } + if room_id is not None: + event_dict["room_id"] = room_id + + return make_test_event( + self.add_hashes_and_signatures_from_other_server( + event_dict, room_version=ROOM_VERSION + ), + room_version=ROOM_VERSION, + ) + + def _build_public_room( + self, extra_state_events: list[JsonDict] | None = None + ) -> tuple[str, list[EventBase]]: + """Build the state DAG of a public room on the remote server. + + Args: + extra_state_events: `_remote_event` kwargs for extra state events to chain on + to the end of the state DAG. + Returns: + The room ID and its state DAG, in causal order. + """ + create_event = self._remote_event( + None, + EventTypes.Create, + "", + {EventContentFields.ROOM_VERSION: ROOM_VERSION.identifier}, + prev_state_events=[], + ) + room_id = create_event.room_id + state_dag = [create_event] + + def append(**kwargs: object) -> None: + state_dag.append( + self._remote_event( + room_id, + prev_state_events=[state_dag[-1].event_id], + **kwargs, # type: ignore[arg-type] + ) + ) + + append( + event_type=EventTypes.Member, + state_key=self.remote_creator_user_id, + content={"membership": Membership.JOIN}, + ) + append( + event_type=EventTypes.JoinRules, + state_key="", + content={"join_rule": JoinRules.PUBLIC}, + ) + for kwargs in extra_state_events or []: + append(**kwargs) + + return room_id, state_dag + + def _join( + self, + room_id: str, + state_dag: list[EventBase], + join_prev_state_events: list[str], + ) -> str: + """Join `room_id` via the real client-server /join API. + + The remote server's /make_join and /send_join HTTP responses are mocked so that + the local homeserver's real federation join code runs against the given state + DAG. `join_prev_state_events` is the state DAG the join event points at. + + Returns the local user's join event ID. + """ + join_template = self._remote_event( + room_id, + EventTypes.Member, + self.local_user_id, + {"membership": Membership.JOIN}, + prev_state_events=join_prev_state_events, + sender=self.local_user_id, + ) + + make_join_path = "/_matrix/federation/v1/make_join/" + "/".join( + urllib.parse.quote_plus(part) for part in (room_id, self.local_user_id) + ) + + async def _get_json( + destination: str, + path: str, + args: QueryParams | None = None, + retry_on_dns_fail: bool = True, + timeout: int | None = None, + ignore_backoff: bool = False, + try_trailing_slash_on_400: bool = False, + parser: ByteParser[T] | None = None, + ) -> JsonDict | T: + if path == make_join_path: + return { + "event": join_template.get_pdu_json(), + "room_version": ROOM_VERSION.identifier, + } + raise NotImplementedError(f"unmocked get_json for {path}") + + send_join_prefix = ( + f"/_matrix/federation/v2/send_join/{urllib.parse.quote_plus(room_id)}/" + ) + + async def _put_json( + destination: str, + path: str, + args: QueryParams | None = None, + data: JsonDict | None = None, + json_data_callback: Callable[[], JsonDict] | None = None, + long_retries: bool = False, + timeout: int | None = None, + ignore_backoff: bool = False, + backoff_on_404: bool = False, + try_trailing_slash_on_400: bool = False, + parser: ByteParser[T] | None = None, + backoff_on_all_error_codes: bool = False, + ) -> JsonDict | T | SendJoinResponse: + if path.startswith(send_join_prefix) and data is not None: + # As the remote server, sign the join event the local server built. + signed_join = make_test_event( + self.add_hashes_and_signatures_from_other_server( + data, room_version=ROOM_VERSION + ), + room_version=ROOM_VERSION, + ) + return SendJoinResponse( + auth_events=[], + state=[], + state_dag=list(state_dag), + event_dict=signed_join.get_pdu_json(), + event=signed_join, + members_omitted=False, + ) + raise NotImplementedError(f"unmocked put_json for {path}") + + self._federation_http_client.get_json.side_effect = _get_json + self._federation_http_client.put_json.side_effect = _put_json + + # Join via the client-server API, telling it which server hosts the room. This + # is a real remote join: everything below the mocked HTTP transport runs. + channel = self.make_request( + "POST", + f"/_matrix/client/v3/join/{urllib.parse.quote(room_id)}" + f"?server_name={self.OTHER_SERVER_NAME}", + content={}, + access_token=self.local_user_tok, + ) + self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body) + + _, join_event_id = self.get_success( + self.store.get_local_current_membership_for_user_in_room( + self.local_user_id, room_id + ) + ) + assert join_event_id is not None + return join_event_id + + def _process_pulled_event(self, event: EventBase) -> None: + """Feed `event` through the real inbound pull path.""" + self.get_success( + self.hs.get_federation_event_handler()._process_pulled_event( + self.OTHER_SERVER_NAME, event, backfilled=False + ) + ) + + def test_pulled_event_fills_in_missing_state_dag(self) -> None: + """A pulled event whose `prev_state_events` we don't have causes us to walk the + state DAG over federation, persist the missing events, and calculate the state + (and auth) before the event ourselves.""" + room_id, state_dag = self._build_public_room() + join_event_id = self._join(room_id, state_dag, [state_dag[-1].event_id]) + + # The remote server adds a room name (which we will be missing), then a topic + # chained on to it. We only receive the topic, so we have to fetch the name. + missing_name = self._remote_event( + room_id, + EventTypes.Name, + "", + {"name": "fetched via the state DAG"}, + prev_state_events=[join_event_id], + ) + pulled_topic = self._remote_event( + room_id, + EventTypes.Topic, + "", + {"topic": "pulled"}, + prev_state_events=[missing_name.event_id], + ) + + async def _post_json( + destination: str, + path: str, + data: JsonDict | None = None, + timeout: int | None = None, + **kwargs: object, + ) -> JsonDict: + if "/get_missing_events/" in path: + return {"events": [missing_name.get_pdu_json()]} + raise NotImplementedError(f"unmocked post_json for {path}") + + self._federation_http_client.post_json.side_effect = _post_json + + self._process_pulled_event(pulled_topic) + + # Both the pulled event and the state DAG event we were missing are now persisted. + self.assertIsNotNone( + self.get_success( + self.store.get_event(missing_name.event_id, allow_none=True) + ) + ) + persisted = self.get_success( + self.store.get_event(pulled_topic.event_id, allow_none=True) + ) + assert persisted is not None + self.assertFalse(persisted.internal_metadata.is_outlier()) + + # The state before the topic was calculated from the fetched state DAG, so the + # room name we had to fetch is part of the state at the topic event. + state = self.get_success( + self.state_storage_controller.get_state_ids_for_event(pulled_topic.event_id) + ) + self.assertEqual(state[(EventTypes.Name, "")], missing_name.event_id) + self.assertEqual(state[(EventTypes.Topic, "")], pulled_topic.event_id) + + def test_pulled_event_is_soft_failed_against_the_current_state_dag(self) -> None: + """A pulled event that passes auth at its own place in the state DAG but fails + against the current state (calculated from the state DAG extremities) is + soft-failed.""" + bob = f"@bob:{self.OTHER_SERVER_NAME}" + room_id, state_dag = self._build_public_room( + extra_state_events=[ + { + "event_type": EventTypes.Member, + "state_key": bob, + "content": {"membership": Membership.JOIN}, + "sender": bob, + } + ] + ) + join_event_id = self._join(room_id, state_dag, [state_dag[-1].event_id]) + + # The creator bans bob, so bob is not in the room in the current state. + ban = self._remote_event( + room_id, + EventTypes.Member, + bob, + {"membership": Membership.BAN}, + prev_state_events=[join_event_id], + ) + self._process_pulled_event(ban) + + # A membership event from bob forked off *before* the ban: it was valid where it + # sits in the DAG (bob was joined), but bob is banned in the current state. + forked = self._remote_event( + room_id, + EventTypes.Member, + bob, + {"membership": Membership.JOIN}, + prev_state_events=[join_event_id], + sender=bob, + ) + self._process_pulled_event(forked) + + persisted = self.get_success( + self.store.get_event(forked.event_id, allow_none=True) + ) + assert persisted is not None + # It is accepted (not rejected) but soft-failed, so it doesn't change the current + # state: bob stays banned. + self.assertFalse(persisted.internal_metadata.is_outlier()) + self.assertTrue(persisted.internal_metadata.soft_failed) + state = self.get_success( + self.state_storage_controller.get_current_state_ids(room_id) + ) + current_bob_membership = self.get_success( + self.store.get_event(state[(EventTypes.Member, bob)]) + ) + self.assertEqual(current_bob_membership.membership, Membership.BAN) From b32e9a36050344cb4660f25a68587e87c6da0b1d Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:52:45 +0100 Subject: [PATCH 22/62] Cap prev_state_events and the state DAG walk --- synapse/handlers/federation_event.py | 20 ++++++++++++++++++++ tests/handlers/test_federation_event.py | 10 +++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index ea983dfa83b..490f23885b3 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1824,6 +1824,11 @@ async def _fetch_missing_state_dag_events( timeout_ms_per_event = 100 iteration = 0 limit = 8 + # #matrix:matrix.org has ~77k state events, so a server that joined at the start and is + # only now syncing up should still be able to fill in the gap. Beyond this we give + # up rather than let a malicious server walk us backwards forever. + # TODO(kegan): we need a staging area else we'll OOM well before this point + max_state_dag_events = 100_000 # we maintain 3 sets: the back set is what the next /gme request will be, and the # /gme response events get bucketed into one of these 3 (seen, missed, back) sets. missed_events: dict[str, MSC4242Event] = {} @@ -1921,6 +1926,13 @@ async def _fetch_missing_state_dag_events( {k: v for (k, v) in remote_events_map.items() if k in unseen_remotes} ) + if len(missed_events) > max_state_dag_events: + logger.warning( + "Giving up walking back through state dag: more than %i events to fetch", + max_state_dag_events, + ) + return [] + # now figure out what the new back set is. In the common case, remote events # will have a long chain of new events e.g A <- B <- C <- D so we want to walk # up this graph if all the events are unseen. If there are seen events (e.g A) @@ -3159,6 +3171,14 @@ def _sanity_check_event(self, ev: EventBase) -> None: ) raise SynapseError(HTTPStatus.BAD_REQUEST, "Too many prev_events") + if supports_msc4242_state_dag(ev) and len(ev.prev_state_events) > 20: + logger.warning( + "Rejecting event %s which has %i prev_state_events", + ev.event_id, + len(ev.prev_state_events), + ) + raise SynapseError(HTTPStatus.BAD_REQUEST, "Too many prev_state_events") + # MSC4242 State DAG events don't list their auth events (they're calculated from # the state DAG), so there's nothing to bound here for them. if not supports_msc4242_state_dag(ev) and len(ev.auth_event_ids()) > 10: diff --git a/tests/handlers/test_federation_event.py b/tests/handlers/test_federation_event.py index 6a8a5ca1bdd..34318ee1dde 100644 --- a/tests/handlers/test_federation_event.py +++ b/tests/handlers/test_federation_event.py @@ -23,7 +23,7 @@ from twisted.internet.testing import MemoryReactor -from synapse.api.errors import AuthError, StoreError +from synapse.api.errors import AuthError, StoreError, SynapseError from synapse.api.room_versions import RoomVersion, RoomVersions from synapse.event_auth import ( check_state_dependent_auth_rules, @@ -1493,6 +1493,14 @@ def test_seen_all(self) -> None: self._prepare_handler({"A", "B", "C", "D"}, seen_all, {}) self._assert_fetches(seen_all, "D", []) + def test_sanity_check_rejects_too_many_prev_state_events(self) -> None: + event = self._make_event([f"$prev{i}" for i in range(21)]) + self.assertRaises( + SynapseError, + self.hs.get_federation_event_handler()._sanity_check_event, + event, + ) + def test_ignores_events_from_other_rooms(self) -> None: linear = self._make_state_dag( { From 8ae810bbc481c9ef63ff16ff591831554d2cb05a Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:03:47 +0100 Subject: [PATCH 23/62] Adjust test comments and make created rooms more accurate --- .../test_federation_pull_state_dag.py | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/tests/federation/test_federation_pull_state_dag.py b/tests/federation/test_federation_pull_state_dag.py index dbb9eae5310..47613fdb97c 100644 --- a/tests/federation/test_federation_pull_state_dag.py +++ b/tests/federation/test_federation_pull_state_dag.py @@ -50,12 +50,11 @@ class FederationPullStateDagTestCase(unittest.FederatingHomeserverTestCase): """Tests for receiving pulled events in a remote MSC4242 State DAG room. - A room is first joined over federation (the same way as the join tests), which leaves - a real, fully-persisted state DAG on the local homeserver. Events are then fed through + A room is first joined over federation. Events are then fed through the real inbound pull path (`_process_pulled_event`), with the remote server's /get_missing_events responses supplied by the test (by mocking the federation HTTP client). This exercises the state DAG walk, outlier persistence, state/auth - calculation and the soft-fail check for real. + calculation and the soft-fail check. """ servlets = [ @@ -156,6 +155,31 @@ def append(**kwargs: object) -> None: state_key="", content={"join_rule": JoinRules.PUBLIC}, ) + append( + event_type=EventTypes.PowerLevels, + state_key="", + content={ + "users": {}, + "users_default": 0, + "events": { + EventTypes.Name: 50, + EventTypes.PowerLevels: 100, + EventTypes.RoomHistoryVisibility: 100, + EventTypes.CanonicalAlias: 50, + EventTypes.RoomAvatar: 50, + EventTypes.Tombstone: 150, + EventTypes.ServerACL: 100, + EventTypes.RoomEncryption: 100, + }, + "events_default": 0, + "state_default": 50, + "ban": 50, + "kick": 50, + "redact": 50, + "invite": 50, + "historical": 100, + }, + ) for kwargs in extra_state_events or []: append(**kwargs) From 02eeef19791713f449e7a6c3f0a8ca9dc3e149ce Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:49:31 +0100 Subject: [PATCH 24/62] Changelog --- changelog.d/20221.misc | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/20221.misc diff --git a/changelog.d/20221.misc b/changelog.d/20221.misc new file mode 100644 index 00000000000..541d2e9b87e --- /dev/null +++ b/changelog.d/20221.misc @@ -0,0 +1 @@ +Add support for receiving events in [MSC4242](https://github.com/matrix-org/matrix-spec-proposals/pull/4242) rooms over federation. From 326f040798059f16a505d36e0c8c09056ffc5266 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:58:20 +0100 Subject: [PATCH 25/62] Add is_state_dag_connected to check the events returned in /send_join --- synapse/handlers/federation_event.py | 16 +++++++ tests/handlers/test_federation_event.py | 60 ++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 7cbc52d350e..dfdd6b259bf 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -61,6 +61,7 @@ validate_event_for_room_version, ) from synapse.events import EventBase +from synapse.events.py_protocol import MSC4242Event from synapse.events.snapshot import ( EventContext, EventPersistencePair, @@ -2428,3 +2429,18 @@ def _sanity_check_event(self, ev: EventBase) -> None: len(ev.auth_event_ids()), ) raise SynapseError(HTTPStatus.BAD_REQUEST, "Too many auth_events") + + +def is_state_dag_connected(state_dag: Collection[MSC4242Event]) -> bool: + """Returns True if the given events form a connected state DAG (MSC4242). + + The DAG is connected if every event referenced in a `prev_state_events` field is + itself present in `state_dag`, i.e. there are no gaps in the DAG. + """ + have_event_ids = {ev.event_id for ev in state_dag} + all_prev_state_events = { + prev_state_event_id + for ev in state_dag + for prev_state_event_id in ev.prev_state_events + } + return all_prev_state_events.issubset(have_event_ids) diff --git a/tests/handlers/test_federation_event.py b/tests/handlers/test_federation_event.py index b423948096b..0e15c8d7447 100644 --- a/tests/handlers/test_federation_event.py +++ b/tests/handlers/test_federation_event.py @@ -23,13 +23,15 @@ from twisted.internet.testing import MemoryReactor from synapse.api.errors import AuthError, StoreError -from synapse.api.room_versions import RoomVersion +from synapse.api.room_versions import RoomVersion, RoomVersions from synapse.event_auth import ( check_state_dependent_auth_rules, check_state_independent_auth_rules, ) +from synapse.events.py_protocol import MSC4242Event, supports_msc4242_state_dag from synapse.events.snapshot import EventContext from synapse.federation.transport.client import StateRequestResponse +from synapse.handlers.federation_event import is_state_dag_connected from synapse.logging.context import LoggingContext from synapse.rest import admin from synapse.rest.client import login, room @@ -1182,3 +1184,59 @@ async def get_room_state( bert_member_event.event_id, "Rejected kick event unexpectedly became part of room state.", ) + + +class IsStateDagConnectedTests(unittest.TestCase): + def setUp(self) -> None: + super().setUp() + self.origin_server_ts = 0 + + def _make_event(self, prev_state_events: list[str]) -> MSC4242Event: + """Build an MSC4242 event with the given `prev_state_events`.""" + # Event IDs are hashes of the event, so vary a field to keep them distinct. + self.origin_server_ts += 1 + event = make_test_event( + { + "type": "m.room.topic", + "state_key": "", + "content": {}, + "sender": "@alice:test", + "origin_server_ts": self.origin_server_ts, + "prev_state_events": prev_state_events, + }, + room_version=RoomVersions.MSC4242v12, + ) + assert supports_msc4242_state_dag(event) + return event + + def test_linear(self) -> None: + create = self._make_event([]) + second = self._make_event([create.event_id]) + third = self._make_event([second.event_id]) + + self.assertTrue(is_state_dag_connected([third, create, second])) + + def test_fork_and_merge(self) -> None: + create = self._make_event([]) + fork_one = self._make_event([create.event_id]) + fork_two = self._make_event([create.event_id]) + fork_three = self._make_event([create.event_id]) + merge = self._make_event([fork_one.event_id, fork_three.event_id]) + + self.assertTrue( + is_state_dag_connected([create, fork_one, fork_two, fork_three, merge]) + ) + + def test_missing_prev_state_event(self) -> None: + create = self._make_event([]) + second = self._make_event([create.event_id]) + unconnected = self._make_event(["$unknown"]) + + self.assertFalse(is_state_dag_connected([create, second, unconnected])) + + def test_missing_intermediate_event(self) -> None: + create = self._make_event([]) + second = self._make_event([create.event_id]) + third = self._make_event([second.event_id]) + + self.assertFalse(is_state_dag_connected([create, third])) From 0e579e2823500231292112101dd214e883e24af2 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:59:11 +0100 Subject: [PATCH 26/62] Calculate auth events on events and auth based on them --- synapse/handlers/federation_event.py | 61 ++++++++++++++++++++-- tests/federation/test_federation_server.py | 3 -- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index dfdd6b259bf..13e944a14c5 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -61,7 +61,7 @@ validate_event_for_room_version, ) from synapse.events import EventBase -from synapse.events.py_protocol import MSC4242Event +from synapse.events.py_protocol import MSC4242Event, supports_msc4242_state_dag from synapse.events.snapshot import ( EventContext, EventPersistencePair, @@ -1834,6 +1834,11 @@ async def _check_event_auth( """ Checks whether an event should be rejected (for failing auth checks). + For MSC4242 State DAG rooms the auth events are calculated from the state before + the event rather than taken from the event, so checking the event against its auth + events (step 4) is the same as checking it against the state before it (step 5), + and only step 4 is performed. + Args: origin: The host the event originates from. This is used to fetch any missing auth events. It can be set to None, but only if we are @@ -1874,7 +1879,7 @@ async def _check_event_auth( # caller rather than swallow with `context.rejected` (since we cannot be # certain that there is a permanent problem with the event). claimed_auth_events = await self._load_or_fetch_auth_events_for_event( - origin, event + origin, event, context ) set_tag( SynapseTags.RESULT_PREFIX + "claimed_auth_events", @@ -1899,6 +1904,9 @@ async def _check_event_auth( context.rejected = RejectedReason.AUTH_ERROR return + if supports_msc4242_state_dag(event): + return + # now check the auth rules pass against the room state before the event # https://spec.matrix.org/v1.3/server-server-api/#checks-performed-on-receipt-of-a-pdu: # 5. Passes authorization rules based on the state before the event, @@ -2112,7 +2120,7 @@ async def _check_for_soft_fail( event.internal_metadata.soft_failed = True async def _load_or_fetch_auth_events_for_event( - self, destination: str | None, event: EventBase + self, destination: str | None, event: EventBase, context: EventContext ) -> Collection[EventBase]: """Fetch this event's auth_events, from database or remote @@ -2125,6 +2133,10 @@ async def _load_or_fetch_auth_events_for_event( to `destination`, or we couldn't validate the signature on the event (which in turn has multiple potential causes). + MSC4242 State DAG events do not list their auth events, so instead we make sure we + have all of the event's `prev_state_events` and then calculate the auth events from + the state before the event. + Args: destination: where to send the /event_auth request. Typically the server that sent us `event` in the first place. @@ -2134,8 +2146,11 @@ async def _load_or_fetch_auth_events_for_event( event: the event whose auth_events we want + context: the state before the event. + Returns: - all of the events listed in `event.auth_events_ids`, after deduplication + all of the events listed in `event.auth_events_ids`, after deduplication. For + MSC4242 State DAG events, the calculated auth events. Raises: AssertionError if some auth events were missing and no `destination` was @@ -2143,7 +2158,11 @@ async def _load_or_fetch_auth_events_for_event( AuthError if we were unable to fetch the auth_events for any reason. """ - event_auth_event_ids = set(event.auth_event_ids()) + event_auth_event_ids = set( + event.prev_state_events + if supports_msc4242_state_dag(event) + else event.auth_event_ids() + ) event_auth_events = await self._store.get_events( event_auth_event_ids, allow_rejected=True ) @@ -2151,6 +2170,8 @@ async def _load_or_fetch_auth_events_for_event( event_auth_events.keys() ) if not missing_auth_event_ids: + if supports_msc4242_state_dag(event): + return await self._load_calculated_auth_events(event, context) return event_auth_events.values() if destination is None: # this shouldn't happen: destination must be set unless we know we have already @@ -2181,6 +2202,8 @@ async def _load_or_fetch_auth_events_for_event( missing_auth_event_ids.difference_update(extra_auth_events.keys()) event_auth_events.update(extra_auth_events) if not missing_auth_event_ids: + if supports_msc4242_state_dag(event): + return await self._load_calculated_auth_events(event, context) return event_auth_events.values() # we still don't have all the auth events. @@ -2194,6 +2217,34 @@ async def _load_or_fetch_auth_events_for_event( # instead we raise an AuthError, which will make the caller ignore it. raise AuthError(code=HTTPStatus.FORBIDDEN, msg="Auth events could not be found") + async def _load_calculated_auth_events( + self, event: MSC4242Event, context: EventContext + ) -> Collection[EventBase]: + """Calculate the auth events for an MSC4242 State DAG event and load them. + + MSC4242 events do not list their auth events, so they are calculated from the + state before the event, in the same way `EventBuilder.build` does when creating + events locally. The calculated auth event IDs are remembered on the event so we + don't need to calculate them again when the event is persisted. + + Rejected events never form part of the room state, so the calculated auth events + cannot be rejected and are loaded without allowing rejected events. + + Args: + event: the event whose auth events we want. + context: the state before the event. + + Returns: + The calculated auth events. + """ + state_ids = await context.get_prev_state_ids() + calculated_auth_event_ids = self._event_auth_handler.compute_auth_events( + event, state_ids + ) + event.internal_metadata.calculated_auth_event_ids = calculated_auth_event_ids + calculated_auth_events = await self._store.get_events(calculated_auth_event_ids) + return calculated_auth_events.values() + @trace @tag_args async def _get_remote_auth_chain_for_event( diff --git a/tests/federation/test_federation_server.py b/tests/federation/test_federation_server.py index 61dcb7446b4..c5bcf9232c7 100644 --- a/tests/federation/test_federation_server.py +++ b/tests/federation/test_federation_server.py @@ -20,7 +20,6 @@ # import logging from http import HTTPStatus -from unittest import skip as skip_test from unittest.mock import Mock from parameterized import parameterized @@ -1234,7 +1233,6 @@ def test_send_join_without_frozen_dicts(self, room_version: str) -> None: """Test send_join with USE_FROZEN_DICTS=False""" self._test_send_join_common(room_version) - @skip_test("requires MSC4242 inbound event auth") @override_config({"experimental_features": {"msc4242_enabled": True}}) def test_send_join_state_dag(self) -> None: """ @@ -1246,7 +1244,6 @@ def test_send_join_state_dag(self) -> None: """ self._test_send_join_common(RoomVersions.MSC4242v12.identifier) - @skip_test("requires MSC4242 inbound event auth") @override_config({"experimental_features": {"msc4242_enabled": True}}) def test_send_join_state_dag_ignores_partial_state(self) -> None: # FIXME: when we support partial joins this test can be deleted From 113b8fab673c64f1a6862b6c5aa046993e6e2ce1 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:35:49 +0100 Subject: [PATCH 27/62] Auth and persist state DAG outliers --- synapse/handlers/federation_event.py | 306 +++++++++++++++++++++++++-- 1 file changed, 291 insertions(+), 15 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 13e944a14c5..aed64ec8f49 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1687,12 +1687,16 @@ async def get_event(event_id: str) -> None: @trace async def _auth_and_persist_outliers( - self, room_id: str, events: Iterable[EventBase] - ) -> None: + self, + room_id: str, + events: Iterable[EventBase], + from_send_join: bool = False, + ) -> bool: """Persist a batch of outlier events fetched from remote servers. We first sort the events to make sure that we process each event's auth_events - before the event itself. + before the event itself. For MSC4242 State DAG events this means sorting by + `prev_state_events`, which is also where auth events are calculated from. We then mark the events as outliers, persist them to the database, and, where appropriate (eg, an invite), awake the notifier. @@ -1701,8 +1705,20 @@ async def _auth_and_persist_outliers( room_id: the room that the events are meant to be in (though this has not yet been checked) events: the events that have been fetched + from_send_join: If True, `events` is the complete state DAG from a /send_join + response, which allows the events to be processed as a batch rather than + one at a time. Only used for MSC4242 State DAG rooms. + Returns: + True if any of the events were rejected. """ event_map = {event.event_id: event for event in events} + if not event_map: + return False + + # There must be an event in events else we would have returned by now + is_state_dag_room = next( + iter(event_map.values()) + ).room_version.msc4242_state_dags event_ids = event_map.keys() set_tag( @@ -1729,26 +1745,39 @@ async def _auth_and_persist_outliers( # We need to persist an event's auth events before the event. auth_graph = { - ev.event_id: [e_id for e_id in ev.auth_event_ids() if e_id in event_map] + ev.event_id: [ + e_id + for e_id in ( + ev.prev_state_events + if supports_msc4242_state_dag(ev) + else ev.auth_event_ids() + ) + if e_id in event_map + ] for ev in event_map.values() } - sorted_auth_event_ids = sorted_topologically(event_map.keys(), auth_graph) - sorted_auth_events = [event_map[e_id] for e_id in sorted_auth_event_ids] + sorted_event_ids = sorted_topologically(event_map.keys(), auth_graph) + sorted_events = [event_map[e_id] for e_id in sorted_event_ids] logger.info( "Persisting %i remaining outliers: %s", - len(sorted_auth_events), - shortstr(e.event_id for e in sorted_auth_events), + len(sorted_events), + shortstr(e.event_id for e in sorted_events), ) + if is_state_dag_room: + return await self._auth_and_persist_state_dag_outliers( + room_id, event_map, sorted_events, from_send_join + ) + + events_and_contexts_to_persist: list[EventPersistencePair] = [] + # get all the auth events for all the events in this batch. By now, they should # have been persisted. auth_event_ids = { - aid for event in sorted_auth_events for aid in event.auth_event_ids() + aid for event in sorted_events for aid in event.auth_event_ids() } auth_map = { - ev.event_id: ev - for ev in sorted_auth_events - if ev.event_id in auth_event_ids + ev.event_id: ev for ev in sorted_events if ev.event_id in auth_event_ids } missing_events = auth_event_ids.difference(auth_map) @@ -1760,8 +1789,6 @@ async def _auth_and_persist_outliers( ) auth_map.update(persisted_events) - events_and_contexts_to_persist: list[EventPersistencePair] = [] - async def prep(event: EventBase) -> None: with nested_logging_context(suffix=event.event_id): auth = [] @@ -1807,7 +1834,7 @@ async def prep(event: EventBase) -> None: events_and_contexts_to_persist.append((event, context)) - for i, event in enumerate(sorted_auth_events): + for i, event in enumerate(sorted_events): await prep(event) # The above function is typically not async, and so won't yield to @@ -1816,6 +1843,11 @@ async def prep(event: EventBase) -> None: if (i + 1) % 1000 == 0: await self._clock.sleep(Duration(seconds=0)) + has_rejected_events = any( + context.rejected is not None + for _, context in events_and_contexts_to_persist + ) + # Also persist the new event in batches for similar reasons as above. for batch in batch_iter(events_and_contexts_to_persist, 1000): await self.persist_events_and_notify( @@ -1827,6 +1859,250 @@ async def prep(event: EventBase) -> None: backfilled=True, ) + return has_rejected_events + + async def _auth_and_persist_state_dag_outliers( + self, + room_id: str, + event_map: dict[str, EventBase], + sorted_events: list[EventBase], + from_send_join: bool, + ) -> bool: + """Persist a batch of MSC4242 State DAG outlier events. + + Other room versions check that an event is allowed according to the auth events + the event itself lists. MSC4242 rooms instead calculate the auth state before the + event and then determine if the event is allowed. + + This means we normally cannot prep the whole batch and then persist the whole + batch, as the state at an event's `prev_state_events` has to have been persisted + before we can work out that event's auth events. The exception is a /send_join + response, where the entire state DAG is in `event_map` and so the state at each + event can be remembered as we go. + + Params: + room_id: the room that the events are meant to be in (though this has + not yet been checked) + event_map: all of the events being persisted, by event ID + sorted_events: the events being persisted, sorted so that an event's + `prev_state_events` come before it + from_send_join: True if `event_map` is the complete state DAG from a + /send_join response + Returns: + True if any of the events were rejected. + """ + has_rejected_events = False + events_and_contexts_to_persist: list[EventPersistencePair] = [] + event_id_to_state_group: dict[str, int] = {} + state_group_to_state_map: dict[int, StateMap[str]] = {} + processed_event_map: dict[str, EventBase] = {} + + async def process(event: EventBase) -> EventPersistencePair: + assert supports_msc4242_state_dag(event) + with nested_logging_context(suffix=event.event_id): + # We can only use the state we have remembered if we have remembered it + # for every one of the event's `prev_state_events`. If we haven't, the + # missing ones were already persisted, so use the database instead. + known_prev_state_maps = None + if from_send_join and all( + prev_state_event_id in event_id_to_state_group + for prev_state_event_id in event.prev_state_events + ): + known_prev_state_maps = { + event_id_to_state_group[prev_state_event_id]: ( + state_group_to_state_map[ + event_id_to_state_group[prev_state_event_id] + ] + ) + for prev_state_event_id in event.prev_state_events + } + + ( + context, + calculated_auth_event_ids, + ) = await self._calculate_state_dag_context( + event, + known_prev_state_maps=known_prev_state_maps, + event_map=processed_event_map, + ) + event.internal_metadata.calculated_auth_event_ids = ( + calculated_auth_event_ids + ) + event.internal_metadata.outlier = True + + batched_auth_events = None + if from_send_join: + # The events in this batch aren't persisted yet, so pull the auth + # events out of the batch, falling back to the database for events we + # had already seen and hence filtered out of the batch. + calculated_auth_events = { + event_id: event_map[event_id] + for event_id in calculated_auth_event_ids + if event_id in event_map + } + missing_auth_event_ids = set(calculated_auth_event_ids).difference( + calculated_auth_events + ) + if missing_auth_event_ids: + calculated_auth_events.update( + await self._store.get_events( + missing_auth_event_ids, + allow_rejected=True, + redact_behaviour=EventRedactBehaviour.as_is, + ) + ) + batched_auth_events = { + event_id: event_map[event_id] + for event_id in itertools.chain( + calculated_auth_event_ids, event.prev_state_events + ) + if event_id in event_map + } + if context.state_group is not None: + event_id_to_state_group[event.event_id] = context.state_group + state_group_to_state_map[ + context.state_group + ] = await context.get_current_state_ids() + else: + calculated_auth_events = await self._store.get_events( + calculated_auth_event_ids, + allow_rejected=True, + redact_behaviour=EventRedactBehaviour.as_is, + ) + + try: + validate_event_for_room_version(event) + await check_state_independent_auth_rules( + self._store, event, batched_auth_events + ) + check_state_dependent_auth_rules( + event, calculated_auth_events.values() + ) + except AuthError as e: + logger.warning("Rejecting %r because %s", event, e) + context.rejected = RejectedReason.AUTH_ERROR + except EventSizeError as e: + if e.unpersistable: + # This event is completely unpersistable. + raise e + # Otherwise, we are somewhat lenient and just persist the event + # as rejected, for moderate compatibility with older Synapse + # versions. + logger.warning("While validating received event %r: %s", event, e) + context.rejected = RejectedReason.OVERSIZED_EVENT + + processed_event_map[event.event_id] = event + return event, context + + if from_send_join: + for i, event in enumerate(sorted_events): + event_and_context = await process(event) + if event_and_context[1].rejected is not None: + has_rejected_events = True + events_and_contexts_to_persist.append(event_and_context) + + # The above function is typically not async, and so won't yield to + # the reactor. For large rooms let's yield to the reactor + # occasionally to ensure we don't block other work. + if (i + 1) % 1000 == 0: + await self._clock.sleep(Duration(seconds=0)) + + for batch in batch_iter(events_and_contexts_to_persist, 1000): + await self.persist_events_and_notify( + room_id, + batch, + # Mark these events as backfilled as they're historic events that + # will eventually be backfilled. For example, missing events we + # fetch during backfill should be marked as backfilled as well. + backfilled=True, + ) + else: + for event in sorted_events: + event_and_context = await process(event) + if event_and_context[1].rejected is not None: + has_rejected_events = True + await self.persist_events_and_notify( + room_id, + [event_and_context], + # Mark these events as backfilled as they're historic events that + # will eventually be backfilled. For example, missing events we + # fetch during backfill should be marked as backfilled as well. + backfilled=True, + ) + + return has_rejected_events + + async def _calculate_state_dag_context( + self, + event: MSC4242Event, + known_prev_state_maps: dict[int, StateMap[str]] | None = None, + event_map: dict[str, EventBase] | None = None, + ) -> tuple[EventContext, list[str]]: + """Calculate the context and auth events for an MSC4242 State DAG event. + + The state before the event is resolved from the event's `prev_state_events`, and + the auth events are then calculated from that state, in the same way + `EventBuilder.build` does when creating events locally. This is the expensive + bit, but it's a one-time cost as the calculated auth events are remembered on + the event. + + Args: + event: The event to calculate the context for. + known_prev_state_maps: The state maps at all of the event's + `prev_state_events`, keyed by state group, if known. This allows events + which have not been persisted yet to be processed. + event_map: Events which may be needed to resolve the state but which have not + been persisted yet. + Returns: + The persisted EventContext and the calculated auth event IDs. + + Raises: + SynapseError if the event has no `prev_state_events` and is not a create + event. + """ + if len(event.prev_state_events) == 0 and event.type != EventTypes.Create: + raise SynapseError(502, f"event {event.event_id} has no prev_state_events") + + if known_prev_state_maps: + if len(known_prev_state_maps) == 1: + state_ids = list(known_prev_state_maps.values())[0] + else: + res = await self._state_resolution_handler.resolve_state_groups( + event.room_id, + event.room_version.identifier, + known_prev_state_maps, + event_map=event_map, + state_res_store=StateResolutionStore( + self._store, self._state_deletion_store + ), + ) + state_ids = await res.get_state(self._state_storage_controller) + else: + state_ids = await self._state_handler.compute_state_after_events( + event.room_id, + event.prev_state_events, + # We cannot filter the state as we need to persist the state group. + state_filter=None, + await_full_state=False, + ) + + # We should always have some resolved state after the prev_state_events, except + # for the create event which is the start of the state DAG. + is_create_event = ( + len(event.prev_state_events) == 0 and event.type == EventTypes.Create + ) + if not is_create_event: + assert len(state_ids) > 0 + + context = await self._state_handler.compute_event_context( + event, + state_ids, + # `compute_event_context` asserts this is None if there is no state, which + # is the case for the create event. + partial_state=None if is_create_event else False, + ) + return context, self._event_auth_handler.compute_auth_events(event, state_ids) + @trace async def _check_event_auth( self, origin: str | None, event: EventBase, context: EventContext From d4f5f8159ef906575d6630cda6c4c7248482b0ac Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:24:54 +0100 Subject: [PATCH 28/62] Support joining MSC4242 rooms --- synapse/handlers/federation.py | 12 ++++------ synapse/handlers/federation_event.py | 34 ++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 52efbe92cc5..d76e58108d8 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -64,7 +64,6 @@ PartialStateConflictError, RequestSendFailed, SynapseError, - UnsupportedRoomVersionError, ) from synapse.api.room_versions import KNOWN_ROOM_VERSIONS, RoomVersion from synapse.crypto.event_signing import compute_event_signature @@ -712,12 +711,6 @@ async def do_invite_join( room_id ) - # See related restriction in /createRoom requests in handlers/room.py - if room_version_obj.msc4242_state_dags: - raise UnsupportedRoomVersionError( - "Homeserver does not support this room version over federation" - ) - ret = await self.federation_client.send_join( host_list, event, @@ -741,6 +734,11 @@ async def do_invite_join( auth_chain = ret.auth_chain auth_chain.sort(key=lambda e: e.depth) + if ret.state_dag is not None: + # MSC4242 State DAG rooms have no separate state and auth chain: the + # state DAG is both, and everything is derived from it. + state = ret.state_dag + logger.debug("do_invite_join auth_chain: %s", auth_chain) logger.debug("do_invite_join state: %s", state) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index aed64ec8f49..2248c0fbee9 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -523,8 +523,10 @@ async def process_remote_join( Args: origin: Where the events came from room_id: - auth_events - state + auth_events: The auth chain from the send_join response. Empty for MSC4242 + State DAG rooms, which do not have one. + state: The current state of the room. For MSC4242 State DAG rooms this is the + state DAG, which includes the auth chains for the state. event room_version: The room version we expect this room to have, and will raise if it doesn't match the version in the create event. @@ -556,6 +558,13 @@ async def process_remote_join( if room_version.identifier != room_version_id: raise SynapseError(400, "Room version mismatch") + if room_version.msc4242_state_dags and not is_state_dag_connected( + [ev for ev in state if supports_msc4242_state_dag(ev)] + ): + # We should have been given a connected state DAG. If there is a gap in it we + # cannot calculate the state, so the response is invalid and we refuse the join. + raise SynapseError(502, "State DAG is not connected") + # persist the auth chain and state events. # # any invalid events here will be marked as rejected, and we'll carry on. @@ -567,9 +576,14 @@ async def process_remote_join( # signatures right now doesn't mean that we will *never* be able to, so it # is premature to reject them. # - await self._auth_and_persist_outliers( - room_id, itertools.chain(auth_events, state) + has_rejected_events = await self._auth_and_persist_outliers( + room_id, + itertools.chain(auth_events, state), + from_send_join=True, ) + if room_version.msc4242_state_dags and has_rejected_events: + # The state DAG must not include rejected events + raise SynapseError(502, "State DAG included rejected events") # and now persist the join event itself. logger.info( @@ -621,6 +635,18 @@ async def process_remote_join( ), ) ) + elif supports_msc4242_state_dag(event): + # We don't blindly trust the state the remote server claims is current, + # and instead calculate the state before the join ourselves, which we can + # do now that the whole state DAG has been persisted. + state_before_event = ( + await self._state_handler.resolve_state_groups_for_events( + event.room_id, event.prev_state_events + ) + ) + state_ids_before_event = await state_before_event.get_state( + self._state_storage_controller + ) else: state_ids_before_event = { (e.type, e.state_key): e.event_id for e in state From 8106eab6c59320c17c6ad828cb08de74f60c94cc Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:25:04 +0100 Subject: [PATCH 29/62] changelog --- changelog.d/20194.misc | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/20194.misc diff --git a/changelog.d/20194.misc b/changelog.d/20194.misc new file mode 100644 index 00000000000..40cdf30f80e --- /dev/null +++ b/changelog.d/20194.misc @@ -0,0 +1 @@ +Add support for joining [MSC4242](https://github.com/matrix-org/matrix-spec-proposals/pull/4242) rooms over federation. From 752f11f35ad17de600df29e2e45cf3602e8f87f8 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:30:01 +0100 Subject: [PATCH 30/62] Apply batched suggestions from code review Co-authored-by: Eric Eastwood --- synapse/handlers/federation_event.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 2248c0fbee9..c682a561ab4 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -563,7 +563,7 @@ async def process_remote_join( ): # We should have been given a connected state DAG. If there is a gap in it we # cannot calculate the state, so the response is invalid and we refuse the join. - raise SynapseError(502, "State DAG is not connected") + raise SynapseError(502, "Unable to join because the remote server passed back a state DAG that is not connected (invalid)") # persist the auth chain and state events. # @@ -583,7 +583,7 @@ async def process_remote_join( ) if room_version.msc4242_state_dags and has_rejected_events: # The state DAG must not include rejected events - raise SynapseError(502, "State DAG included rejected events") + raise SynapseError(502, "Unable to join because the remote server passed back a state DAG that includes rejected events (invalid)") # and now persist the join event itself. logger.info( @@ -2005,7 +2005,7 @@ async def process(event: EventBase) -> EventPersistencePair: event, calculated_auth_events.values() ) except AuthError as e: - logger.warning("Rejecting %r because %s", event, e) + logger.warning("Rejecting %r while persisting state DAG because %s", event, e) context.rejected = RejectedReason.AUTH_ERROR except EventSizeError as e: if e.unpersistable: @@ -2136,7 +2136,7 @@ async def _check_event_auth( """ Checks whether an event should be rejected (for failing auth checks). - For MSC4242 State DAG rooms the auth events are calculated from the state before + For MSC4242 State DAG rooms, the auth events are calculated from the state before the event rather than taken from the event, so checking the event against its auth events (step 4) is the same as checking it against the state before it (step 5), and only step 4 is performed. From 1e7cecc1b809055d2470fa22b275b6349ada7a67 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:50:02 +0100 Subject: [PATCH 31/62] Review comments --- synapse/handlers/federation_event.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index c682a561ab4..b889e1061ef 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1973,6 +1973,9 @@ async def process(event: EventBase) -> EventPersistencePair: calculated_auth_events.update( await self._store.get_events( missing_auth_event_ids, + # Allow rejected events so events which depend on them fail with coherent + # error messages. If we filtered them out here, we'd instead fail with + # a missing events error which is misleading. allow_rejected=True, redact_behaviour=EventRedactBehaviour.as_is, ) @@ -1992,6 +1995,9 @@ async def process(event: EventBase) -> EventPersistencePair: else: calculated_auth_events = await self._store.get_events( calculated_auth_event_ids, + # Allow rejected events so events which depend on them fail with coherent + # error messages. If we filtered them out here, we'd instead fail with + # a missing events error which is misleading. allow_rejected=True, redact_behaviour=EventRedactBehaviour.as_is, ) @@ -2107,7 +2113,7 @@ async def _calculate_state_dag_context( state_ids = await self._state_handler.compute_state_after_events( event.room_id, event.prev_state_events, - # We cannot filter the state as we need to persist the state group. + # There is no filter to apply here state_filter=None, await_full_state=False, ) @@ -2115,9 +2121,13 @@ async def _calculate_state_dag_context( # We should always have some resolved state after the prev_state_events, except # for the create event which is the start of the state DAG. is_create_event = ( - len(event.prev_state_events) == 0 and event.type == EventTypes.Create + len(event.prev_state_events) == 0 + and event.type == EventTypes.Create + and event.get_state_key() == "" ) if not is_create_event: + # We must have calculated room state for every event by now. + # Only the create event can have no room state (since it's the first event in the room) assert len(state_ids) > 0 context = await self._state_handler.compute_event_context( @@ -2136,11 +2146,6 @@ async def _check_event_auth( """ Checks whether an event should be rejected (for failing auth checks). - For MSC4242 State DAG rooms, the auth events are calculated from the state before - the event rather than taken from the event, so checking the event against its auth - events (step 4) is the same as checking it against the state before it (step 5), - and only step 4 is performed. - Args: origin: The host the event originates from. This is used to fetch any missing auth events. It can be set to None, but only if we are @@ -2207,6 +2212,10 @@ async def _check_event_auth( return if supports_msc4242_state_dag(event): + # For MSC4242 State DAG rooms, the auth events are calculated from the state before + # the event rather than taken from the event, so checking the event against its auth + # events (step 4) is the same as checking it against the state before it (step 5), + # and only step 4 is performed. return # now check the auth rules pass against the room state before the event @@ -2544,7 +2553,9 @@ async def _load_calculated_auth_events( event, state_ids ) event.internal_metadata.calculated_auth_event_ids = calculated_auth_event_ids - calculated_auth_events = await self._store.get_events(calculated_auth_event_ids) + calculated_auth_events = await self._store.get_events( + calculated_auth_event_ids, allow_rejected=False + ) return calculated_auth_events.values() @trace From 0e962a141805889271fd2078784dbf1f7a01e907 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:35:09 +0100 Subject: [PATCH 32/62] Review comments --- synapse/handlers/federation_event.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index b889e1061ef..f64d02c7482 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1921,6 +1921,7 @@ async def _auth_and_persist_state_dag_outliers( events_and_contexts_to_persist: list[EventPersistencePair] = [] event_id_to_state_group: dict[str, int] = {} state_group_to_state_map: dict[int, StateMap[str]] = {} + # Tracks which events have been processed in causal order (prev_state_events first) processed_event_map: dict[str, EventBase] = {} async def process(event: EventBase) -> EventPersistencePair: @@ -1958,6 +1959,8 @@ async def process(event: EventBase) -> EventPersistencePair: batched_auth_events = None if from_send_join: + # event_map is the complete state DAG + # The events in this batch aren't persisted yet, so pull the auth # events out of the batch, falling back to the database for events we # had already seen and hence filtered out of the batch. @@ -1966,6 +1969,8 @@ async def process(event: EventBase) -> EventPersistencePair: for event_id in calculated_auth_event_ids if event_id in event_map } + # In theory we should not be missing any auth events because event_map contains + # the entire state DAG, but if we do we can ask the database if it knows about them. missing_auth_event_ids = set(calculated_auth_event_ids).difference( calculated_auth_events ) @@ -1980,6 +1985,9 @@ async def process(event: EventBase) -> EventPersistencePair: redact_behaviour=EventRedactBehaviour.as_is, ) ) + # In order to run auth rule checks we need the events referenced in the event + # (prev_state_events) and the events we calculated (auth_events) so load them now. + # If we are missing any, auth rule checks will produce a coherent error message. batched_auth_events = { event_id: event_map[event_id] for event_id in itertools.chain( @@ -1987,6 +1995,10 @@ async def process(event: EventBase) -> EventPersistencePair: ) if event_id in event_map } + # If we have calculated state before this event, remember it so we don't need + # to keep asking the database. The state we are remembering is StateMap[event_id] + # along with the state group integer, which is exactly the data needed by + # _calculate_state_dag_context if context.state_group is not None: event_id_to_state_group[event.event_id] = context.state_group state_group_to_state_map[ @@ -2115,6 +2127,9 @@ async def _calculate_state_dag_context( event.prev_state_events, # There is no filter to apply here state_filter=None, + # we need to have finished processing the entire state DAG before we can + # compute the state for new events, but this function is called WHEN we are processing + # the entire state DAG. To wait here would be circular, we'd deadlock. await_full_state=False, ) From cc56001577adfccdf6045ebab6887d26deeb391a Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:36:28 +0100 Subject: [PATCH 33/62] Linting --- synapse/handlers/federation_event.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index f64d02c7482..9f3615e482c 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -563,7 +563,10 @@ async def process_remote_join( ): # We should have been given a connected state DAG. If there is a gap in it we # cannot calculate the state, so the response is invalid and we refuse the join. - raise SynapseError(502, "Unable to join because the remote server passed back a state DAG that is not connected (invalid)") + raise SynapseError( + 502, + "Unable to join because the remote server passed back a state DAG that is not connected (invalid)", + ) # persist the auth chain and state events. # @@ -583,7 +586,10 @@ async def process_remote_join( ) if room_version.msc4242_state_dags and has_rejected_events: # The state DAG must not include rejected events - raise SynapseError(502, "Unable to join because the remote server passed back a state DAG that includes rejected events (invalid)") + raise SynapseError( + 502, + "Unable to join because the remote server passed back a state DAG that includes rejected events (invalid)", + ) # and now persist the join event itself. logger.info( @@ -2023,7 +2029,9 @@ async def process(event: EventBase) -> EventPersistencePair: event, calculated_auth_events.values() ) except AuthError as e: - logger.warning("Rejecting %r while persisting state DAG because %s", event, e) + logger.warning( + "Rejecting %r while persisting state DAG because %s", event, e + ) context.rejected = RejectedReason.AUTH_ERROR except EventSizeError as e: if e.unpersistable: From e98c653c2ae3a8293752897640fca82bd70969cc Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:46:15 +0100 Subject: [PATCH 34/62] Review comments --- synapse/handlers/federation_event.py | 31 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 9f3615e482c..ff6b126e1ed 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1999,7 +1999,6 @@ async def process(event: EventBase) -> EventPersistencePair: for event_id in itertools.chain( calculated_auth_event_ids, event.prev_state_events ) - if event_id in event_map } # If we have calculated state before this event, remember it so we don't need # to keep asking the database. The state we are remembering is StateMap[event_id] @@ -2117,6 +2116,7 @@ async def _calculate_state_dag_context( if known_prev_state_maps: if len(known_prev_state_maps) == 1: + # There's only 1 state map so we don't need to do state resolution at all. state_ids = list(known_prev_state_maps.values())[0] else: res = await self._state_resolution_handler.resolve_state_groups( @@ -2133,25 +2133,26 @@ async def _calculate_state_dag_context( state_ids = await self._state_handler.compute_state_after_events( event.room_id, event.prev_state_events, - # There is no filter to apply here + # There is no filter to apply here and we want all the events anyway since we need + # to persist the entire calculate state. state_filter=None, - # we need to have finished processing the entire state DAG before we can - # compute the state for new events, but this function is called WHEN we are processing - # the entire state DAG. To wait here would be circular, we'd deadlock. + # we need to have finished processing the events causally prior to `event` in the + # state DAG before we can compute the state for this `event`, but this function is + # called unilaterally WHEN we are processing events in the state DAG. + # To wait here would be circular, we'd deadlock. await_full_state=False, ) # We should always have some resolved state after the prev_state_events, except # for the create event which is the start of the state DAG. - is_create_event = ( - len(event.prev_state_events) == 0 - and event.type == EventTypes.Create - and event.get_state_key() == "" - ) + is_create_event = len(event.prev_state_events) == 0 and ( + event.type, + event.get_state_key(), + ) == (EventTypes.Create, "") if not is_create_event: # We must have calculated room state for every event by now. # Only the create event can have no room state (since it's the first event in the room) - assert len(state_ids) > 0 + assert len(state_ids) > 0, "State at prev_state_events was not calculated" context = await self._state_handler.compute_event_context( event, @@ -2561,9 +2562,6 @@ async def _load_calculated_auth_events( events locally. The calculated auth event IDs are remembered on the event so we don't need to calculate them again when the event is persisted. - Rejected events never form part of the room state, so the calculated auth events - cannot be rejected and are loaded without allowing rejected events. - Args: event: the event whose auth events we want. context: the state before the event. @@ -2577,7 +2575,10 @@ async def _load_calculated_auth_events( ) event.internal_metadata.calculated_auth_event_ids = calculated_auth_event_ids calculated_auth_events = await self._store.get_events( - calculated_auth_event_ids, allow_rejected=False + calculated_auth_event_ids, + # Rejected events never form part of the room state, so the calculated auth events + # cannot be rejected and are loaded without allowing rejected events. + allow_rejected=False, ) return calculated_auth_events.values() From bf174a9fdcc43c84642573c47b22d9f59f7167b2 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:12:00 +0100 Subject: [PATCH 35/62] Review comments --- synapse/handlers/federation_event.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index ff6b126e1ed..76d1a15e826 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1973,7 +1973,6 @@ async def process(event: EventBase) -> EventPersistencePair: calculated_auth_events = { event_id: event_map[event_id] for event_id in calculated_auth_event_ids - if event_id in event_map } # In theory we should not be missing any auth events because event_map contains # the entire state DAG, but if we do we can ask the database if it knows about them. @@ -2111,7 +2110,11 @@ async def _calculate_state_dag_context( SynapseError if the event has no `prev_state_events` and is not a create event. """ - if len(event.prev_state_events) == 0 and event.type != EventTypes.Create: + is_create_event = len(event.prev_state_events) == 0 and ( + event.type, + event.get_state_key(), + ) == (EventTypes.Create, "") + if len(event.prev_state_events) == 0 and not is_create_event: raise SynapseError(502, f"event {event.event_id} has no prev_state_events") if known_prev_state_maps: @@ -2145,10 +2148,7 @@ async def _calculate_state_dag_context( # We should always have some resolved state after the prev_state_events, except # for the create event which is the start of the state DAG. - is_create_event = len(event.prev_state_events) == 0 and ( - event.type, - event.get_state_key(), - ) == (EventTypes.Create, "") + if not is_create_event: # We must have calculated room state for every event by now. # Only the create event can have no room state (since it's the first event in the room) From e32d828be68a526c8a4e9020a8043658eecc1e9a Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:14:17 +0100 Subject: [PATCH 36/62] Update synapse/handlers/federation_event.py Co-authored-by: Eric Eastwood --- synapse/handlers/federation_event.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 76d1a15e826..544f6a7e14c 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -2143,6 +2143,19 @@ async def _calculate_state_dag_context( # state DAG before we can compute the state for this `event`, but this function is # called unilaterally WHEN we are processing events in the state DAG. # To wait here would be circular, we'd deadlock. + # By this point, we expect to have already finished processing the + # events *causally prior* to `event` in the state DAG because this + # function is called unilaterally WHEN we are processing events in the + # state DAG all the way down the chain and the caller ensures we process + # events in topological order. + # + # If this assumption is ever violated, `compute_state_after_events` will + # yell loudly about missing state groups. + # + # In the context of partially stated rooms (faster remote room joins) + # this would be part of background step verifying the entire state DAG, + # so we know that we don't have the full state processed yet. To wait + # here would be circular, we'd deadlock. await_full_state=False, ) From c3db7cf7e8528dc4ff1916f4607bb3e4826e4194 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:37:47 +0100 Subject: [PATCH 37/62] Update synapse/handlers/federation_event.py Co-authored-by: Eric Eastwood --- synapse/handlers/federation_event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 544f6a7e14c..1e0f381f550 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -588,7 +588,7 @@ async def process_remote_join( # The state DAG must not include rejected events raise SynapseError( 502, - "Unable to join because the remote server passed back a state DAG that includes rejected events (invalid)", + "Unable to join because the remote server passed back a state DAG that includes rejected events (invalid)", ) # and now persist the join event itself. From 0cbd358b1297f0f3157ff80b1023b41b0bca099e Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:42:49 +0100 Subject: [PATCH 38/62] Review comments --- synapse/handlers/federation_event.py | 20 ++++++++++++++------ synapse/storage/databases/main/rejections.py | 13 +++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 1e0f381f550..c5ce65c538d 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1768,9 +1768,14 @@ async def _auth_and_persist_outliers( # # This is just an optimisation, so it doesn't need to be watertight - the event # persister does another round of deduplication. + has_rejected_events = False seen_remotes = await self._store.have_seen_events(room_id, event_map.keys()) - for s in seen_remotes: - event_map.pop(s, None) + if seen_remotes: + for s in seen_remotes: + event_map.pop(s, None) + rejected_event_ids = self._store.get_rejected_events(seen_remotes) + if rejected_event_ids: + has_rejected_events = True # XXX: it might be possible to kick this process off in parallel with fetching # the events. @@ -1875,10 +1880,12 @@ async def prep(event: EventBase) -> None: if (i + 1) % 1000 == 0: await self._clock.sleep(Duration(seconds=0)) - has_rejected_events = any( - context.rejected is not None - for _, context in events_and_contexts_to_persist - ) + if not has_rejected_events: + # Check if any of the persisted events were rejected + has_rejected_events = any( + context.rejected is not None + for _, context in events_and_contexts_to_persist + ) # Also persist the new event in batches for similar reasons as above. for batch in batch_iter(events_and_contexts_to_persist, 1000): @@ -2120,6 +2127,7 @@ async def _calculate_state_dag_context( if known_prev_state_maps: if len(known_prev_state_maps) == 1: # There's only 1 state map so we don't need to do state resolution at all. + # resolve_state_groups should not be called for a single state group as per its docstring state_ids = list(known_prev_state_maps.values())[0] else: res = await self._state_resolution_handler.resolve_state_groups( diff --git a/synapse/storage/databases/main/rejections.py b/synapse/storage/databases/main/rejections.py index c73c3d761dd..9ea91a1edc1 100644 --- a/synapse/storage/databases/main/rejections.py +++ b/synapse/storage/databases/main/rejections.py @@ -20,6 +20,7 @@ # import logging +from typing import Set from synapse.storage._base import SQLBaseStore @@ -35,3 +36,15 @@ async def get_rejection_reason(self, event_id: str) -> str | None: allow_none=True, desc="get_rejection_reason", ) + + async def get_rejected_events(self, event_ids: Set[str]) -> Set[str]: + """Filter the provided event IDs to only return rejected events.""" + rows = await self.db_pool.simple_select_many_batch( + table="rejections", + column="event_id", + iterable=event_ids, + retcols=("event_id",), + keyvalues={}, + desc="get_rejected_events", + ) + return {r[0] for r in rows} From ae5bff756c981fc7760984f523a4a36e35f54488 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:44:06 +0100 Subject: [PATCH 39/62] Linting --- synapse/storage/databases/main/rejections.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/synapse/storage/databases/main/rejections.py b/synapse/storage/databases/main/rejections.py index 9ea91a1edc1..389d0643a01 100644 --- a/synapse/storage/databases/main/rejections.py +++ b/synapse/storage/databases/main/rejections.py @@ -20,7 +20,6 @@ # import logging -from typing import Set from synapse.storage._base import SQLBaseStore @@ -37,7 +36,7 @@ async def get_rejection_reason(self, event_id: str) -> str | None: desc="get_rejection_reason", ) - async def get_rejected_events(self, event_ids: Set[str]) -> Set[str]: + async def get_rejected_events(self, event_ids: set[str]) -> set[str]: """Filter the provided event IDs to only return rejected events.""" rows = await self.db_pool.simple_select_many_batch( table="rejections", From 40ef6c50da35faa5eb2c0a3c13fbf78af2367e3e Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:42:52 +0100 Subject: [PATCH 40/62] Update synapse/handlers/federation_event.py Co-authored-by: Eric Eastwood --- synapse/handlers/federation_event.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index c5ce65c538d..5fe9e74bdfe 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -2147,10 +2147,6 @@ async def _calculate_state_dag_context( # There is no filter to apply here and we want all the events anyway since we need # to persist the entire calculate state. state_filter=None, - # we need to have finished processing the events causally prior to `event` in the - # state DAG before we can compute the state for this `event`, but this function is - # called unilaterally WHEN we are processing events in the state DAG. - # To wait here would be circular, we'd deadlock. # By this point, we expect to have already finished processing the # events *causally prior* to `event` in the state DAG because this # function is called unilaterally WHEN we are processing events in the From fbf97a6318f82752c765ad02535ce7c3a00ff7d5 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:55:05 +0100 Subject: [PATCH 41/62] Review comments --- synapse/handlers/federation_event.py | 2 +- synapse/storage/databases/main/rejections.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 5fe9e74bdfe..8b77269a7d9 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1773,7 +1773,7 @@ async def _auth_and_persist_outliers( if seen_remotes: for s in seen_remotes: event_map.pop(s, None) - rejected_event_ids = self._store.get_rejected_events(seen_remotes) + rejected_event_ids = await self._store.has_rejected_event_ids(seen_remotes) if rejected_event_ids: has_rejected_events = True diff --git a/synapse/storage/databases/main/rejections.py b/synapse/storage/databases/main/rejections.py index 389d0643a01..1e0e87ae28e 100644 --- a/synapse/storage/databases/main/rejections.py +++ b/synapse/storage/databases/main/rejections.py @@ -36,14 +36,16 @@ async def get_rejection_reason(self, event_id: str) -> str | None: desc="get_rejection_reason", ) - async def get_rejected_events(self, event_ids: set[str]) -> set[str]: - """Filter the provided event IDs to only return rejected events.""" + async def has_rejected_event_ids(self, event_ids: set[str]) -> set[str]: + """Filter the provided event IDs to only return rejected events. + This does not check if the event IDs are persisted (missing events would appear as non-rejected). + """ rows = await self.db_pool.simple_select_many_batch( table="rejections", column="event_id", iterable=event_ids, retcols=("event_id",), keyvalues={}, - desc="get_rejected_events", + desc="has_rejected_event_ids", ) return {r[0] for r in rows} From 947b9107e0a148c51b305a42bf11edc4feacc64e Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:50:18 +0100 Subject: [PATCH 42/62] Various bug fixes and enable Complement tests for MSC4242 - Enable MSC4242 complement tests - Fix an issue which caused cascade rejections to not be applied to events in the /send_join response becau se we didn't remember in-memory which events were rejected. - Accept rejected events in the /send_join response, it gives us a bit of robustness at no safety cost. - Recalculate room state if backfilled events caused the state DAG fwd extrems to change. - Don't filter out already seen events from event_map, so the statement "the event_map is the complete stat e DAG" still holds. - Add validation to length of prev_state_events - Don't validate auth_events length for state DAG events as it is done too early, before we've had time to calculate the auth events (and validation is moot anyway given it isn't attacker controlled anymore) - Let backfilled events update state dag fwd extrems - Add test for edge case with backfilled state events --- .../conf/workers-shared-extra.yaml.j2 | 1 + scripts-dev/complement.sh | 1 + synapse/event_auth.py | 13 +- synapse/handlers/federation_event.py | 110 ++++++++++++-- synapse/storage/controllers/persist_events.py | 49 ++++++- tests/handlers/test_federation_event.py | 134 ++++++++++++++++++ 6 files changed, 290 insertions(+), 18 deletions(-) diff --git a/docker/complement/conf/workers-shared-extra.yaml.j2 b/docker/complement/conf/workers-shared-extra.yaml.j2 index 251018e1d2f..bafcd9f3d14 100644 --- a/docker/complement/conf/workers-shared-extra.yaml.j2 +++ b/docker/complement/conf/workers-shared-extra.yaml.j2 @@ -147,6 +147,7 @@ experimental_features: msc4354_enabled: true # `/sync` `state_after` msc4222_enabled: true + msc4242_enabled: true server_notices: system_mxid_localpart: _server diff --git a/scripts-dev/complement.sh b/scripts-dev/complement.sh index d8e553f446f..ec2e26961cc 100755 --- a/scripts-dev/complement.sh +++ b/scripts-dev/complement.sh @@ -287,6 +287,7 @@ main() { ./tests/msc4306 ./tests/msc4222 ./tests/msc4429 + ./tests/msc4242 ) # Export the list of test packages as a space-separated environment variable, so other diff --git a/synapse/event_auth.py b/synapse/event_auth.py index 81a454b5442..c2a18889a35 100644 --- a/synapse/event_auth.py +++ b/synapse/event_auth.py @@ -164,6 +164,7 @@ async def check_state_independent_auth_rules( store: _EventSourceStore, event: "EventBase", batched_auth_events: Mapping[str, "EventBase"] | None = None, + batched_rejections: Mapping[str, str] | None = None, ) -> None: """Check that an event complies with auth rules that are independent of room state @@ -175,6 +176,11 @@ async def check_state_independent_auth_rules( event: the event being checked. batched_auth_events: if the event being authed is part of a batch, any events from the same batch that may be necessary to auth the current event + batched_rejections: event ID to rejection reason for any events in `batched_auth_events` + which have been rejected. Events in the batch have not been persisted + yet, so their rejection is recorded neither on the event nor in the database, + and the caller has to tell us. Needed for MSC4242 State DAG rooms, where + rejection cascades through `prev_state_events`. Raises: AuthError if the checks fail @@ -244,11 +250,14 @@ async def check_state_independent_auth_rules( ) # 2.3 If there are entries which were themselves rejected under the checks performed on # receipt of a PDU, reject. - if prev_state_event.rejected_reason is not None: + rejected_reason = prev_state_event.rejected_reason + if rejected_reason is None and batched_rejections is not None: + rejected_reason = batched_rejections.get(prev_state_event.event_id) + if rejected_reason is not None: raise AuthError( 403, f"During auth for event {event.event_id} in room {event.room_id}, event has a " - + f"prev_state_event which is rejected ({prev_state_event.rejected_reason}): " + + f"prev_state_event which is rejected ({rejected_reason}): " + f"{prev_state_event.event_id}", ) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 8b77269a7d9..265e2673c1e 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -585,10 +585,19 @@ async def process_remote_join( from_send_join=True, ) if room_version.msc4242_state_dags and has_rejected_events: - # The state DAG must not include rejected events - raise SynapseError( - 502, - "Unable to join because the remote server passed back a state DAG that includes rejected events (invalid)", + # A conformant server excludes rejected events from the state DAG it sends to + # new joiners, so their presence means the remote server is faulty. We log + # that rather than refusing the join: rejected events are excluded from the + # state DAG forward extremities, so they cannot contribute to the room state + # we calculate, and any event referencing one is rejected in turn. Refusing + # would cost availability without buying any safety. + logger.warning( + "Remote server %s sent a state DAG containing rejected events when we " + "joined %s. This is a bug in that server: rejected events must be " + "excluded from /send_join responses. Joining anyway; the rejected events " + "form no part of the room state.", + origin, + room_id, ) # and now persist the join event itself. @@ -899,6 +908,12 @@ async def _process_pulled_events( backfilled: True if this is part of a historical batch of events (inhibits notification to clients, and validation of device keys.) """ + if not events: + return + + # Our callers only ever pass events for a single room. + room_id = next(iter(events)).room_id + set_tag( SynapseTags.FUNC_ARG_PREFIX + "event_ids", str([event.event_id for event in events]), @@ -968,10 +983,50 @@ async def _process_new_pulled_events(new_events: Collection[EventBase]) -> None: # ascending) because one backfill event is likely to be the `prev_event` of # the next event we're going to process. sorted_events = sorted(new_events, key=lambda x: x.depth) + + # Backfilled events are persisted without recalculating the current state, + # which is right for the room DAG but not always for the state DAG. Backfill + # can teach us about state events we have never seen, which then belong on + # the state DAG frontier and so do change the current state. That happens + # when an event's `prev_events` reach state events which its + # `prev_state_events` do not, because the state DAG walk in + # /get_missing_events only ever follows `prev_state_events`. + # This is defence-in-depth: prev_events on honest servers is a superset of the state + # events in that event's prev_state_events, and only when they disagree (e.g. refer to + # an event on a fork) will this occur, but we have to handle the adversarial case too. + # + # Remember the frontier so that we can recalculate the current state if it moves. + state_dag_extremities_before: frozenset[str] | None = None + if ( + backfilled # Non-backfilled pulled events will automatically update the current state, this check is only for /backfill + and sorted_events + and supports_msc4242_state_dag(sorted_events[0]) + ): + state_dag_extremities_before = ( + await self._store.get_state_dag_extremities(room_id) + ) + for ev in sorted_events: with nested_logging_context(ev.event_id): await self._process_pulled_event(origin, ev, backfilled=backfilled) + if state_dag_extremities_before is not None: + state_dag_extremities_after = ( + await self._store.get_state_dag_extremities(room_id) + ) + if state_dag_extremities_after != state_dag_extremities_before: + # This is unusual because it implies the graph induced by prev_events vs prev_state_events + # has diverged, so warn about it. + logger.warning( + "Backfill from %s moved the state DAG forward extremities of %s from %s " + "to %s, recalculating the current state", + origin, + room_id, + shortstr(state_dag_extremities_before), + shortstr(state_dag_extremities_after), + ) + await self._state_handler.update_current_state(room_id) + # Check if we've already tried to process these events at some point in the # past. We aren't concerned with the expontntial backoff here, just whether it # has failed to be processed before. @@ -1770,9 +1825,10 @@ async def _auth_and_persist_outliers( # persister does another round of deduplication. has_rejected_events = False seen_remotes = await self._store.have_seen_events(room_id, event_map.keys()) + unseen_event_map = dict(event_map) if seen_remotes: for s in seen_remotes: - event_map.pop(s, None) + unseen_event_map.pop(s, None) rejected_event_ids = await self._store.has_rejected_event_ids(seen_remotes) if rejected_event_ids: has_rejected_events = True @@ -1789,12 +1845,12 @@ async def _auth_and_persist_outliers( if supports_msc4242_state_dag(ev) else ev.auth_event_ids() ) - if e_id in event_map + if e_id in unseen_event_map ] - for ev in event_map.values() + for ev in unseen_event_map.values() } - sorted_event_ids = sorted_topologically(event_map.keys(), auth_graph) - sorted_events = [event_map[e_id] for e_id in sorted_event_ids] + sorted_event_ids = sorted_topologically(unseen_event_map.keys(), auth_graph) + sorted_events = [unseen_event_map[e_id] for e_id in sorted_event_ids] logger.info( "Persisting %i remaining outliers: %s", len(sorted_events), @@ -1922,7 +1978,10 @@ async def _auth_and_persist_state_dag_outliers( Params: room_id: the room that the events are meant to be in (though this has not yet been checked) - event_map: all of the events being persisted, by event ID + event_map: every event we were given, by event ID. This includes events we + have already persisted and so are not persisting again, because we may + still need them to work out the auth events of an event we have not seen + before. On /send_join this is the complete state DAG. sorted_events: the events being persisted, sorted so that an event's `prev_state_events` come before it from_send_join: True if `event_map` is the complete state DAG from a @@ -1936,6 +1995,12 @@ async def _auth_and_persist_state_dag_outliers( state_group_to_state_map: dict[int, StateMap[str]] = {} # Tracks which events have been processed in causal order (prev_state_events first) processed_event_map: dict[str, EventBase] = {} + # The events in this batch which we have rejected, and why. Rejection cascades + # through `prev_state_events`, but nothing in the batch is persisted until we have + # processed all of it, so an event we rejected a moment ago still looks accepted + # both in the database and on the in-memory event. We therefore have to remember + # our own decisions and tell the auth rules about them. + rejections: dict[str, str] = {} async def process(event: EventBase) -> EventPersistencePair: assert supports_msc4242_state_dag(event) @@ -1974,9 +2039,9 @@ async def process(event: EventBase) -> EventPersistencePair: if from_send_join: # event_map is the complete state DAG - # The events in this batch aren't persisted yet, so pull the auth - # events out of the batch, falling back to the database for events we - # had already seen and hence filtered out of the batch. + # The events being persisted aren't in the database yet, so pull the + # auth events out of `event_map`, which holds the whole state DAG, + # including the events we had already persisted before this join. calculated_auth_events = { event_id: event_map[event_id] for event_id in calculated_auth_event_ids @@ -2028,7 +2093,10 @@ async def process(event: EventBase) -> EventPersistencePair: try: validate_event_for_room_version(event) await check_state_independent_auth_rules( - self._store, event, batched_auth_events + self._store, + event, + batched_auth_events, + batched_rejections=rejections, ) check_state_dependent_auth_rules( event, calculated_auth_events.values() @@ -2048,6 +2116,8 @@ async def process(event: EventBase) -> EventPersistencePair: logger.warning("While validating received event %r: %s", event, e) context.rejected = RejectedReason.OVERSIZED_EVENT + if context.rejected is not None: + rejections[event.event_id] = context.rejected processed_event_map[event.event_id] = event return event, context @@ -2827,7 +2897,17 @@ def _sanity_check_event(self, ev: EventBase) -> None: ) raise SynapseError(HTTPStatus.BAD_REQUEST, "Too many prev_events") - if len(ev.auth_event_ids()) > 10: + if supports_msc4242_state_dag(ev) and len(ev.prev_state_events) > 20: + logger.warning( + "Rejecting event %s which has %i prev_state_events", + ev.event_id, + len(ev.prev_state_events), + ) + raise SynapseError(HTTPStatus.BAD_REQUEST, "Too many prev_state_events") + + # MSC4242 State DAG events don't list their auth events (they're calculated from + # the state DAG), so there's nothing to bound here for them. + if not supports_msc4242_state_dag(ev) and len(ev.auth_event_ids()) > 10: logger.warning( "Rejecting event %s which has %i auth_events", ev.event_id, diff --git a/synapse/storage/controllers/persist_events.py b/synapse/storage/controllers/persist_events.py index 13f958d9983..2994d637bc5 100644 --- a/synapse/storage/controllers/persist_events.py +++ b/synapse/storage/controllers/persist_events.py @@ -653,7 +653,54 @@ async def _persist_event_batch( state_delta_for_room = None new_state_dag_extrems = None - if not backfilled: + if all_supports_msc4242_state_dag(chunk) and backfilled: + # Backfilled events do not update the room DAG extremities or the + # current state, but a state event which is new to us still has to move + # the state DAG forward extremities. + # + # In practice the only thing which gets here are state events from /send_join. + # Those events are persisted as backfilled so that they do not appear down + # /sync, but the state DAG forwards extremities must be updated otherwise when + # we hit the join event we won't calculate the current state correctly due to having + # stale extremities. + # + # Catching up on a room we are already in does not come through here: the + # state DAG walk in /get_missing_events fetches `prev_state_events` at + # backfilled=False, and we refuse to persist any event whose + # `prev_state_events` we do not already hold, so an event cannot reach us + # ahead of the prev_state_events it references. + # + # There is one edge case here: when prev_events disagrees with prev_state_events. E.g: + # + # prev_events graph We know S1, S2, S3 + # S1 <- S2 <- S3 Sx = state event, M = message + # ^-- S4 <- S5 <- M Backfilling M returns S4, S5 alongside it + # + # Suppose M incorrectly has prev_state_events=[S3] but prev_events=[S5] + # then /get_missing_events will NOT fetch S4, S5 and only when you /backfill + # the prev_events graph will you suddenly become aware of [S4, S5], + # which will hit this code path. In this case, we should be recalculating the current + # state to take S4,S5 into account but this code path does not. + # We rely on the caller (_process_pulled_events) to handle this case by checking if + # _backfilled_ events changed the state DAG forward extremities, and in the extremely + # unlikely case it did, warn in the logs and recalculate the room state. + seen_event_ids = await self.main_store.have_seen_events( + room_id, [event.event_id for event, _ in chunk] + ) + unseen_chunk = [ + (event, context) + for event, context in chunk + if event.event_id not in seen_event_ids + ] + if unseen_chunk: + new_state_dag_extrems = ( + await self._calculate_new_state_dag_extremities( + room_id, + await self.main_store.get_state_dag_extremities(room_id), + unseen_chunk, + ) + ) + elif not backfilled: if all_supports_msc4242_state_dag(chunk): with Measure( self._clock, diff --git a/tests/handlers/test_federation_event.py b/tests/handlers/test_federation_event.py index 0e15c8d7447..554de2b3ddf 100644 --- a/tests/handlers/test_federation_event.py +++ b/tests/handlers/test_federation_event.py @@ -1240,3 +1240,137 @@ def test_missing_intermediate_event(self) -> None: third = self._make_event([second.event_id]) self.assertFalse(is_state_dag_connected([create, third])) + + +class BackfilledStateDagEventTests(unittest.FederatingHomeserverTestCase): + servlets = [ + admin.register_servlets, + login.register_servlets, + room.register_servlets, + ] + + @unittest.override_config({"experimental_features": {"msc4242_enabled": True}}) + def test_backfilled_state_events_update_current_state(self) -> None: + """State events we only learn about through backfill must reach the current state. + + An event's `prev_events` can reach state events which its `prev_state_events` do + not. This should not normally happen but the protocol allows it, so we have to handle it. + The state DAG walk in /get_missing_events only follows `prev_state_events`, + so those state events are never fetched that way, and we first see them when the + `prev_events` graph is backfilled. They are still state events which nothing in + our state DAG references, so they belong on the state DAG frontier and therefore + change the current state. + + prev_state_events graph We know MEMBER and TOPIC + Sx = state event + MEMBER <- TOPIC Backfill hands us S4 and S5, forking at MEMBER + `-- S4 <- S5 + + The frontier becomes {TOPIC, S5}, so S5 has to be merged into the current state. + """ + OTHER_USER = f"@user:{self.OTHER_SERVER_NAME}" + main_store = self.hs.get_datastores().main + federation_event_handler = self.hs.get_federation_event_handler() + + user_id = self.register_user("kermit", "test") + tok = self.login("kermit", "test") + room_id = self.helper.create_room_as( + room_creator=user_id, + tok=tok, + room_version=RoomVersions.MSC4242v12.identifier, + ) + room_version = self.get_success(main_store.get_room_version(room_id)) + self.assertTrue(room_version.msc4242_state_dags) + + # allow the remote user to send state events + self.helper.send_state( + room_id, + "m.room.power_levels", + {"events_default": 0, "state_default": 0}, + tok=tok, + ) + + # add the remote user to the room. The backfilled branch forks here. + member_event = self.get_success( + event_injection.inject_member_event(self.hs, room_id, OTHER_USER, "join") + ) + + # Move the state DAG on locally, so that what we backfill below is a genuine fork + # rather than an extension of the only branch we have. + topic_event_id = self.helper.send_state( + room_id, "m.room.topic", {"topic": "local"}, tok=tok + )["event_id"] + + extremities_before = self.get_success( + main_store.get_state_dag_extremities(room_id) + ) + self.assertIncludes({topic_event_id}, extremities_before, exact=True) + + # S4 and S5 are a branch of the state DAG which we have never been told about: + # nothing we hold references them via `prev_state_events`, so the state DAG walk + # would never have fetched them. + s4 = make_test_event( + self.add_hashes_and_signatures_from_other_server( + { + "type": "test_state_type", + "state_key": "", + "room_id": room_id, + "sender": OTHER_USER, + "prev_events": [member_event.event_id], + "prev_state_events": [member_event.event_id], + "origin_server_ts": 1, + "depth": 10, + "content": {"body": "s4"}, + }, + room_version, + ), + room_version, + ) + s5 = make_test_event( + self.add_hashes_and_signatures_from_other_server( + { + "type": "test_state_type", + "state_key": "", + "room_id": room_id, + "sender": OTHER_USER, + "prev_events": [s4.event_id], + "prev_state_events": [s4.event_id], + "origin_server_ts": 2, + "depth": 11, + "content": {"body": "s5"}, + }, + room_version, + ), + room_version, + ) + + self.get_success( + federation_event_handler._process_pulled_events( + self.OTHER_SERVER_NAME, [s4, s5], backfilled=True + ) + ) + + # The tip of the backfilled branch is now a state DAG forward extremity, and the + # branch we already had still is too: the fork is unmerged in the state DAG. + extremities_after = self.get_success( + main_store.get_state_dag_extremities(room_id) + ) + self.assertIncludes( + {topic_event_id, s5.event_id}, + extremities_after, + exact=True, + message="the backfilled state event did not become a state DAG forward extremity", + ) + + # ...and resolving that frontier is what the current state must reflect. + state_map = self.get_success(main_store.get_partial_current_state_ids(room_id)) + self.assertEqual( + state_map.get(("test_state_type", "")), + s5.event_id, + "the state event learned through backfill is not part of the current state", + ) + self.assertEqual( + state_map.get(("m.room.topic", "")), + topic_event_id, + "merging in the backfilled branch lost the state on the branch we had", + ) From f3dce9bf4a096b2ac8751e28dc7638b7a01c21ca Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:58:09 +0100 Subject: [PATCH 43/62] Linting --- synapse/handlers/federation_event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 265e2673c1e..86e648656d3 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -998,7 +998,7 @@ async def _process_new_pulled_events(new_events: Collection[EventBase]) -> None: # Remember the frontier so that we can recalculate the current state if it moves. state_dag_extremities_before: frozenset[str] | None = None if ( - backfilled # Non-backfilled pulled events will automatically update the current state, this check is only for /backfill + backfilled # Non-backfilled pulled events will automatically update the current state, this check is only for /backfill and sorted_events and supports_msc4242_state_dag(sorted_events[0]) ): From dc0d0dc40ce8ba610efd8faa42d3e476a6880c7e Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:04:08 +0100 Subject: [PATCH 44/62] Stricter check to ensure missing await would be caught --- synapse/handlers/federation_event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 86e648656d3..f55352fb6fd 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1830,7 +1830,7 @@ async def _auth_and_persist_outliers( for s in seen_remotes: unseen_event_map.pop(s, None) rejected_event_ids = await self._store.has_rejected_event_ids(seen_remotes) - if rejected_event_ids: + if len(rejected_event_ids) > 0: has_rejected_events = True # XXX: it might be possible to kick this process off in parallel with fetching From d73bc421a87d59c90f120e5a5a58c8be92ab1919 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:00:11 +0100 Subject: [PATCH 45/62] Add missing RejectionsStore for workers --- synapse/app/generic_worker.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/synapse/app/generic_worker.py b/synapse/app/generic_worker.py index 159cd44237e..6014d328ba0 100644 --- a/synapse/app/generic_worker.py +++ b/synapse/app/generic_worker.py @@ -93,6 +93,7 @@ from synapse.storage.databases.main.pusher import PusherWorkerStore from synapse.storage.databases.main.receipts import ReceiptsWorkerStore from synapse.storage.databases.main.registration import RegistrationWorkerStore +from synapse.storage.databases.main.rejections import RejectionsStore from synapse.storage.databases.main.relations import RelationsWorkerStore from synapse.storage.databases.main.room import RoomWorkerStore from synapse.storage.databases.main.roommember import RoomMemberWorkerStore @@ -153,6 +154,7 @@ class GenericWorkerStore( EventFederationWorkerStore, EventPushActionsWorkerStore, PurgeEventsStore, + RejectionsStore, StateGroupWorkerStore, SignatureWorkerStore, UserErasureWorkerStore, From 2b40cd004c69be7a1c6d51161c7ffccf4024f232 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:45:26 +0100 Subject: [PATCH 46/62] bugfix: rejoining a room with rejected events incorrectly authorised events that should have been rejected The cascade rejection only worked on in-flight events, not seen events. We now overwrite events in event_map with rejected events (thus setting .rejected_reason) to ensure we continue to cascade rejections correctly. Complement test asserts this. --- synapse/handlers/federation_event.py | 29 +++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index f55352fb6fd..a24cbbf4be4 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1832,6 +1832,31 @@ async def _auth_and_persist_outliers( rejected_event_ids = await self._store.has_rejected_event_ids(seen_remotes) if len(rejected_event_ids) > 0: has_rejected_events = True + if is_state_dag_room: + # The copies in `event_map` came off the wire, so they claim to be + # unrejected even for events we rejected when we first saw them, e.g + # during an earlier join of this room. Swap in the persisted copies, + # which carry `rejected_reason`, so that rejection cascades into the + # events in this batch which reference them via `prev_state_events`. + # + # Events rejected during *this* batch are not covered by this: they + # have not been persisted yet, so they are tracked separately and + # passed to the auth rules as `batched_rejections`. + persisted_rejected_events = await self._store.get_events( + rejected_event_ids, + allow_rejected=True, + redact_behaviour=EventRedactBehaviour.as_is, + ) + missing = rejected_event_ids - set(persisted_rejected_events) + if missing: + # We have seen these events and they have rejection rows, so we + # should be able to load them. If we cannot, rejection would + # silently stop cascading, so complain loudly instead. + raise AssertionError( + f"Could not load rejected events {missing} " + + f"out of {rejected_event_ids}" + ) + event_map.update(persisted_rejected_events) # XXX: it might be possible to kick this process off in parallel with fetching # the events. @@ -1981,7 +2006,9 @@ async def _auth_and_persist_state_dag_outliers( event_map: every event we were given, by event ID. This includes events we have already persisted and so are not persisting again, because we may still need them to work out the auth events of an event we have not seen - before. On /send_join this is the complete state DAG. + before. Any of those which were rejected are the persisted copies, + so they carry `rejected_reason`. On /send_join this is the complete + state DAG. sorted_events: the events being persisted, sorted so that an event's `prev_state_events` come before it from_send_join: True if `event_map` is the complete state DAG from a From b65f0b70250d55499e233106512580b743637dc8 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:04:19 +0100 Subject: [PATCH 47/62] Review comments --- synapse/handlers/federation_event.py | 32 +++++++++++-------- synapse/storage/controllers/persist_events.py | 2 +- tests/handlers/test_federation_event.py | 2 +- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index a24cbbf4be4..57cf38c9d43 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -912,7 +912,9 @@ async def _process_pulled_events( return # Our callers only ever pass events for a single room. - room_id = next(iter(events)).room_id + first_event = next(iter(events)) + room_id = first_event.room_id + is_state_dag_room = first_event.room_version.msc4242_state_dags set_tag( SynapseTags.FUNC_ARG_PREFIX + "event_ids", @@ -948,6 +950,7 @@ async def _process_pulled_events( new_events: list[EventBase] = [] for event in events: + assert event.room_id == room_id event_id = event.event_id # If we've already seen this event ID... @@ -1000,7 +1003,7 @@ async def _process_new_pulled_events(new_events: Collection[EventBase]) -> None: if ( backfilled # Non-backfilled pulled events will automatically update the current state, this check is only for /backfill and sorted_events - and supports_msc4242_state_dag(sorted_events[0]) + and is_state_dag_room ): state_dag_extremities_before = ( await self._store.get_state_dag_extremities(room_id) @@ -1874,28 +1877,29 @@ async def _auth_and_persist_outliers( ] for ev in unseen_event_map.values() } - sorted_event_ids = sorted_topologically(unseen_event_map.keys(), auth_graph) - sorted_events = [unseen_event_map[e_id] for e_id in sorted_event_ids] + unseen_sorted_event_ids = sorted_topologically(unseen_event_map.keys(), auth_graph) + unseen_sorted_events = [unseen_event_map[e_id] for e_id in unseen_sorted_event_ids] logger.info( "Persisting %i remaining outliers: %s", - len(sorted_events), - shortstr(e.event_id for e in sorted_events), + len(unseen_sorted_events), + shortstr(e.event_id for e in unseen_sorted_events), ) if is_state_dag_room: - return await self._auth_and_persist_state_dag_outliers( - room_id, event_map, sorted_events, from_send_join + has_new_rejected_events = await self._auth_and_persist_state_dag_outliers( + room_id, event_map, unseen_sorted_events, from_send_join ) + return has_rejected_events or has_new_rejected_events events_and_contexts_to_persist: list[EventPersistencePair] = [] # get all the auth events for all the events in this batch. By now, they should # have been persisted. auth_event_ids = { - aid for event in sorted_events for aid in event.auth_event_ids() + aid for event in unseen_sorted_events for aid in event.auth_event_ids() } auth_map = { - ev.event_id: ev for ev in sorted_events if ev.event_id in auth_event_ids + ev.event_id: ev for ev in unseen_sorted_events if ev.event_id in auth_event_ids } missing_events = auth_event_ids.difference(auth_map) @@ -1952,7 +1956,7 @@ async def prep(event: EventBase) -> None: events_and_contexts_to_persist.append((event, context)) - for i, event in enumerate(sorted_events): + for i, event in enumerate(unseen_sorted_events): await prep(event) # The above function is typically not async, and so won't yield to @@ -2027,7 +2031,7 @@ async def _auth_and_persist_state_dag_outliers( # processed all of it, so an event we rejected a moment ago still looks accepted # both in the database and on the in-memory event. We therefore have to remember # our own decisions and tell the auth rules about them. - rejections: dict[str, str] = {} + rejected_event_id_to_reason_map: dict[str, str] = {} async def process(event: EventBase) -> EventPersistencePair: assert supports_msc4242_state_dag(event) @@ -2123,7 +2127,7 @@ async def process(event: EventBase) -> EventPersistencePair: self._store, event, batched_auth_events, - batched_rejections=rejections, + batched_rejections=rejected_event_id_to_reason_map, ) check_state_dependent_auth_rules( event, calculated_auth_events.values() @@ -2144,7 +2148,7 @@ async def process(event: EventBase) -> EventPersistencePair: context.rejected = RejectedReason.OVERSIZED_EVENT if context.rejected is not None: - rejections[event.event_id] = context.rejected + rejected_event_id_to_reason_map[event.event_id] = context.rejected processed_event_map[event.event_id] = event return event, context diff --git a/synapse/storage/controllers/persist_events.py b/synapse/storage/controllers/persist_events.py index 2994d637bc5..e2f936d9981 100644 --- a/synapse/storage/controllers/persist_events.py +++ b/synapse/storage/controllers/persist_events.py @@ -660,7 +660,7 @@ async def _persist_event_batch( # # In practice the only thing which gets here are state events from /send_join. # Those events are persisted as backfilled so that they do not appear down - # /sync, but the state DAG forwards extremities must be updated otherwise when + # /sync, but the state DAG forward extremities must be updated otherwise when # we hit the join event we won't calculate the current state correctly due to having # stale extremities. # diff --git a/tests/handlers/test_federation_event.py b/tests/handlers/test_federation_event.py index 554de2b3ddf..8c952352c1c 100644 --- a/tests/handlers/test_federation_event.py +++ b/tests/handlers/test_federation_event.py @@ -1295,7 +1295,7 @@ def test_backfilled_state_events_update_current_state(self) -> None: event_injection.inject_member_event(self.hs, room_id, OTHER_USER, "join") ) - # Move the state DAG on locally, so that what we backfill below is a genuine fork + # Move the state DAG locally, so that the backfill below is a genuine fork # rather than an extension of the only branch we have. topic_event_id = self.helper.send_state( room_id, "m.room.topic", {"topic": "local"}, tok=tok From 32876bb3c5c3e8ea1a43455574b69048c7a56b6a Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:06:56 +0100 Subject: [PATCH 48/62] Linting --- synapse/handlers/federation_event.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 57cf38c9d43..4aa8fe42cfb 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1877,8 +1877,12 @@ async def _auth_and_persist_outliers( ] for ev in unseen_event_map.values() } - unseen_sorted_event_ids = sorted_topologically(unseen_event_map.keys(), auth_graph) - unseen_sorted_events = [unseen_event_map[e_id] for e_id in unseen_sorted_event_ids] + unseen_sorted_event_ids = sorted_topologically( + unseen_event_map.keys(), auth_graph + ) + unseen_sorted_events = [ + unseen_event_map[e_id] for e_id in unseen_sorted_event_ids + ] logger.info( "Persisting %i remaining outliers: %s", len(unseen_sorted_events), @@ -1899,7 +1903,9 @@ async def _auth_and_persist_outliers( aid for event in unseen_sorted_events for aid in event.auth_event_ids() } auth_map = { - ev.event_id: ev for ev in unseen_sorted_events if ev.event_id in auth_event_ids + ev.event_id: ev + for ev in unseen_sorted_events + if ev.event_id in auth_event_ids } missing_events = auth_event_ids.difference(auth_map) From 0d1ded94b61857e333b30b453899aaba50eafc34 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:41:52 +0100 Subject: [PATCH 49/62] bugfix: fix an issue where a rejoin would calculate state incorrectly if one of the prev_state_events was already persisted Tested in Complement as TestMSC4242RejoinMergesOldAndNewStateDAGBranches --- synapse/handlers/federation_event.py | 75 ++++++++++++++++++++++------ synapse/storage/controllers/state.py | 7 ++- 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 4aa8fe42cfb..14b75125711 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -2039,25 +2039,70 @@ async def _auth_and_persist_state_dag_outliers( # our own decisions and tell the auth rules about them. rejected_event_id_to_reason_map: dict[str, str] = {} + async def prev_state_maps(event: EventBase) -> dict[int, StateMap[str]]: + """The state at each of an event's `prev_state_events`, by state group. + + A `prev_state_event` processed earlier in this batch has not been persisted + yet, so the state at it is only in memory. A `prev_state_event` persisted in the past + e.g. from a previous room join are stored in the database. A single event can reference + both, so fill in from the database on a miss and remember it for the rest of + the batch. + """ + missing = [ + prev_state_event_id + for prev_state_event_id in event.prev_state_events + if prev_state_event_id not in event_id_to_state_group + ] + if missing: + # Anything we have not remembered was persisted before this batch, so + # the database knows the state at it. + groups = ( + await self._state_storage_controller.get_state_group_for_events( + missing, await_full_state=False, + ) + ) + unknown = [ + prev_state_event_id + for prev_state_event_id in missing + if groups.get(prev_state_event_id) is None + ] + if unknown: + # `get_state_group_for_events` claims to raise when it can't + # find a state group, but that only covers lookups which reached the + # database :/ cached entries come back as None. + # We can't calculate the state at `event` without knowing the state groups + # for ALL prev_state_events so bail out if we're missing them. + raise RuntimeError( + f"No state group for prev_state_events {unknown} of " + f"{event.event_id}, so the state before it cannot be calculated" + ) + + group_to_state = ( + await self._state_storage_controller.get_state_for_groups( + set(groups.values()) + ) + ) + # Now populate the in-memory map + for prev_state_event_id, state_group in groups.items(): + event_id_to_state_group[prev_state_event_id] = state_group + state_group_to_state_map[state_group] = group_to_state[state_group] + + return { + event_id_to_state_group[prev_state_event_id]: state_group_to_state_map[ + event_id_to_state_group[prev_state_event_id] + ] + for prev_state_event_id in event.prev_state_events + } + async def process(event: EventBase) -> EventPersistencePair: assert supports_msc4242_state_dag(event) with nested_logging_context(suffix=event.event_id): - # We can only use the state we have remembered if we have remembered it - # for every one of the event's `prev_state_events`. If we haven't, the - # missing ones were already persisted, so use the database instead. + # On a /send_join the events in this batch are not persisted until we + # have processed all of them, so the state at an event's + # `prev_state_events` can be spread across memory and the database. known_prev_state_maps = None - if from_send_join and all( - prev_state_event_id in event_id_to_state_group - for prev_state_event_id in event.prev_state_events - ): - known_prev_state_maps = { - event_id_to_state_group[prev_state_event_id]: ( - state_group_to_state_map[ - event_id_to_state_group[prev_state_event_id] - ] - ) - for prev_state_event_id in event.prev_state_events - } + if from_send_join: + known_prev_state_maps = await prev_state_maps(event) ( context, diff --git a/synapse/storage/controllers/state.py b/synapse/storage/controllers/state.py index b2e1d61a333..4b3c978ccdc 100644 --- a/synapse/storage/controllers/state.py +++ b/synapse/storage/controllers/state.py @@ -533,7 +533,12 @@ async def get_state_group_for_events( Raises: RuntimeError if we don't have a state group for one or more of the events - (ie. they are outliers or unknown) + (ie. they are outliers or unknown) AND the lookup reached the database. + + Callers MUST NOT rely on this to determine missing events, as cached responses + return None for the state group instead of raising. + + TODO: audit every caller, as some of them may be relying on the current behaviour. """ if await_full_state: await self._partial_state_events_tracker.await_full_state(event_ids) From 55ad431e26f6f93c918b239de71b9a083b12485e Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:02:51 +0100 Subject: [PATCH 50/62] Linting --- synapse/handlers/federation_event.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 14b75125711..293f99e38cb 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -2039,7 +2039,7 @@ async def _auth_and_persist_state_dag_outliers( # our own decisions and tell the auth rules about them. rejected_event_id_to_reason_map: dict[str, str] = {} - async def prev_state_maps(event: EventBase) -> dict[int, StateMap[str]]: + async def prev_state_maps(event: MSC4242Event) -> dict[int, StateMap[str]]: """The state at each of an event's `prev_state_events`, by state group. A `prev_state_event` processed earlier in this batch has not been persisted @@ -2058,7 +2058,8 @@ async def prev_state_maps(event: EventBase) -> dict[int, StateMap[str]]: # the database knows the state at it. groups = ( await self._state_storage_controller.get_state_group_for_events( - missing, await_full_state=False, + missing, + await_full_state=False, ) ) unknown = [ @@ -2094,7 +2095,7 @@ async def prev_state_maps(event: EventBase) -> dict[int, StateMap[str]]: for prev_state_event_id in event.prev_state_events } - async def process(event: EventBase) -> EventPersistencePair: + async def process(event: MSC4242Event) -> EventPersistencePair: assert supports_msc4242_state_dag(event) with nested_logging_context(suffix=event.event_id): # On a /send_join the events in this batch are not persisted until we @@ -2205,6 +2206,9 @@ async def process(event: EventBase) -> EventPersistencePair: if from_send_join: for i, event in enumerate(sorted_events): + assert supports_msc4242_state_dag( + event + ) # to convert from EventBase to MSC4242Event event_and_context = await process(event) if event_and_context[1].rejected is not None: has_rejected_events = True @@ -2227,6 +2231,9 @@ async def process(event: EventBase) -> EventPersistencePair: ) else: for event in sorted_events: + assert supports_msc4242_state_dag( + event + ) # to convert from EventBase to MSC4242Event event_and_context = await process(event) if event_and_context[1].rejected is not None: has_rejected_events = True From fd9a6a42c2ba02a04d9daa9ad5b261fa2d35cbab Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:24:23 +0100 Subject: [PATCH 51/62] Block other federation endpoints for MSC4242 rooms; ensure the join event is connected to the state DAG This PR only handles /send_join and connected events in /send, so everything else should return early with coherent error messages. The join event must be connected to the main state DAG but we previously didn't ensure this. --- synapse/federation/federation_server.py | 21 +++++++++++++++++++++ synapse/handlers/federation.py | 10 ++++++++++ synapse/handlers/federation_event.py | 21 ++++++++++++++++++++- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/synapse/federation/federation_server.py b/synapse/federation/federation_server.py index 95aff08ddf2..3e051d83066 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -233,12 +233,22 @@ async def _handle_old_staged_events(self) -> None: # We pause a bit so that we don't start handling all rooms at once. await self._clock.sleep(Duration(seconds=random.uniform(0, 0.1))) + async def _assert_not_state_dag_room(self, room_id: str, endpoint: str) -> None: + room_version = await self.store.get_room_version(room_id) + if room_version.msc4242_state_dags: + raise SynapseError( + 500, + f"{endpoint} is not implemented for MSC4242 state DAG rooms", + errcode=Codes.UNKNOWN, + ) + async def on_backfill_request( self, origin: str, room_id: str, versions: list[str], limit: int ) -> tuple[int, dict[str, Any]]: async with self._server_linearizer.queue((origin, room_id)): origin_host, _ = parse_server_name(origin) await self.check_server_matches_acl(origin_host, room_id) + await self._assert_not_state_dag_room(room_id, "/backfill") pdus = await self.handler.on_backfill_request( origin, room_id, versions, limit @@ -654,6 +664,7 @@ async def on_room_state_request( await self._event_auth_handler.assert_host_in_room(room_id, origin) origin_host, _ = parse_server_name(origin) await self.check_server_matches_acl(origin_host, room_id) + await self._assert_not_state_dag_room(room_id, "/state") # we grab the linearizer to protect ourselves from servers which hammer # us. In theory we might already have the response to this query @@ -694,6 +705,7 @@ async def on_state_ids_request( await self._event_auth_handler.assert_host_in_room(room_id, origin) origin_host, _ = parse_server_name(origin) await self.check_server_matches_acl(origin_host, room_id) + await self._assert_not_state_dag_room(room_id, "/state_ids") resp = await self._state_ids_resp_cache.wrap( (room_id, event_id), @@ -1154,6 +1166,13 @@ async def _on_send_membership_event( # Note that get_room_version throws if the room does not exist here. room_version = await self.store.get_room_version(room_id) + if room_version.msc4242_state_dags and membership_type != Membership.JOIN: + raise SynapseError( + 500, + f"/send_{membership_type} is not implemented for MSC4242 state DAG rooms", + errcode=Codes.UNKNOWN, + ) + if await self.store.is_partial_state_room(room_id): # If our server is still only partially joined, we can't give a complete # response to /send_join, /send_knock or /send_leave. @@ -1247,6 +1266,7 @@ async def on_event_auth( await self._event_auth_handler.assert_host_in_room(room_id, origin) origin_host, _ = parse_server_name(origin) await self.check_server_matches_acl(origin_host, room_id) + await self._assert_not_state_dag_room(room_id, "/event_auth") time_now = self._clock.time_msec() auth_pdus = await self.handler.on_event_auth(event_id, room_id) @@ -1332,6 +1352,7 @@ async def on_get_missing_events( origin, room_id, earliest_events, latest_events, limit ) else: + await self._assert_not_state_dag_room(room_id, "/get_missing_events") missing_events = await self.handler.on_get_missing_events( origin, room_id, earliest_events, latest_events, limit ) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index d76e58108d8..f3a024da3a3 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -254,6 +254,9 @@ async def maybe_backfill( Returns: True if we actually tried to backfill something, otherwise False. """ + if (await self.store.get_room_version(room_id)).msc4242_state_dags: + return False + # Starting the processing time here so we can include the room backfill # linearizer lock queue in the timing processing_start_time = self.clock.time_msec() if record_time else 0 @@ -1459,6 +1462,13 @@ async def on_invite_request( if event.state_key is None: raise SynapseError(400, "The invite event did not have a state key") + if room_version.msc4242_state_dags: + raise SynapseError( + 500, + "/invite is not implemented for MSC4242 state DAG rooms", + errcode=Codes.UNKNOWN, + ) + is_blocked = await self.store.is_room_blocked(event.room_id) if is_blocked: raise SynapseError(403, "This room has been blocked on this server") diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 293f99e38cb..11b507d4a5a 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -559,7 +559,11 @@ async def process_remote_join( raise SynapseError(400, "Room version mismatch") if room_version.msc4242_state_dags and not is_state_dag_connected( - [ev for ev in state if supports_msc4242_state_dag(ev)] + [ + ev + for ev in itertools.chain(state, [event]) + if supports_msc4242_state_dag(ev) + ] ): # We should have been given a connected state DAG. If there is a gap in it we # cannot calculate the state, so the response is invalid and we refuse the join. @@ -1256,6 +1260,15 @@ async def _compute_event_context_with_maybe_missing_prevs( if not missing_prevs: return await self._state_handler.compute_event_context(event) + if event.room_version.msc4242_state_dags: + raise FederationError( + "ERROR", + 500, + "Fetching the state at missing prev_events is not implemented for " + "MSC4242 state DAG rooms", + affected=event_id, + ) + logger.info( "_compute_event_context_with_maybe_missing_prevs(event_id=%s): Event in room %s is missing prev_events %s: " "calculating state for a backwards extremity", @@ -2684,6 +2697,12 @@ async def _load_or_fetch_auth_events_for_event( if supports_msc4242_state_dag(event): return await self._load_calculated_auth_events(event, context) return event_auth_events.values() + if supports_msc4242_state_dag(event): + raise AuthError( + code=HTTPStatus.FORBIDDEN, + msg=f"Unknown prev_state_events {missing_auth_event_ids}; fetching them " + "is not implemented for MSC4242 state DAG rooms", + ) if destination is None: # this shouldn't happen: destination must be set unless we know we have already # persisted the auth events. From ef22e48973b88736ffbd400872d52ff344b3b50e Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:34:43 +0100 Subject: [PATCH 52/62] Ensure we sanity check events in the /send_join response --- synapse/handlers/federation_event.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 11b507d4a5a..7eb6d7819ca 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -2039,6 +2039,9 @@ async def _auth_and_persist_state_dag_outliers( Returns: True if any of the events were rejected. """ + for event in sorted_events: + self._sanity_check_event(event) + has_rejected_events = False events_and_contexts_to_persist: list[EventPersistencePair] = [] event_id_to_state_group: dict[str, int] = {} From 5cb407f9394789e3ad18a548e46a03b09a17e576 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:46:06 +0100 Subject: [PATCH 53/62] Only scan for rejected event IDs in state DAG rooms to avoid needless scanning for other room versions --- synapse/handlers/federation_event.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 7eb6d7819ca..8fbd5ffddd0 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -1845,10 +1845,12 @@ async def _auth_and_persist_outliers( if seen_remotes: for s in seen_remotes: unseen_event_map.pop(s, None) - rejected_event_ids = await self._store.has_rejected_event_ids(seen_remotes) - if len(rejected_event_ids) > 0: - has_rejected_events = True - if is_state_dag_room: + if is_state_dag_room: + rejected_event_ids = await self._store.has_rejected_event_ids( + seen_remotes + ) + if len(rejected_event_ids) > 0: + has_rejected_events = True # The copies in `event_map` came off the wire, so they claim to be # unrejected even for events we rejected when we first saw them, e.g # during an earlier join of this room. Swap in the persisted copies, From 982efb78ef0d27730d975b16275ba95e4ede0d9d Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:54:25 +0100 Subject: [PATCH 54/62] Assert there can be no missing auth event IDs since we already ensured this --- synapse/handlers/federation_event.py | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 8fbd5ffddd0..41821789d96 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -2143,29 +2143,21 @@ async def process(event: MSC4242Event) -> EventPersistencePair: # The events being persisted aren't in the database yet, so pull the # auth events out of `event_map`, which holds the whole state DAG, # including the events we had already persisted before this join. - calculated_auth_events = { - event_id: event_map[event_id] - for event_id in calculated_auth_event_ids - } - # In theory we should not be missing any auth events because event_map contains - # the entire state DAG, but if we do we can ask the database if it knows about them. missing_auth_event_ids = set(calculated_auth_event_ids).difference( - calculated_auth_events + event_map ) if missing_auth_event_ids: - calculated_auth_events.update( - await self._store.get_events( - missing_auth_event_ids, - # Allow rejected events so events which depend on them fail with coherent - # error messages. If we filtered them out here, we'd instead fail with - # a missing events error which is misleading. - allow_rejected=True, - redact_behaviour=EventRedactBehaviour.as_is, - ) + raise SynapseError( + 502, + f"state DAG is missing the calculated auth events {missing_auth_event_ids} " + f"for event {event.event_id}", ) + calculated_auth_events = { + event_id: event_map[event_id] + for event_id in calculated_auth_event_ids + } # In order to run auth rule checks we need the events referenced in the event # (prev_state_events) and the events we calculated (auth_events) so load them now. - # If we are missing any, auth rule checks will produce a coherent error message. batched_auth_events = { event_id: event_map[event_id] for event_id in itertools.chain( From 82d52c978f1cfc13dc2a3e157ca3eb5f5391c5c5 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:29:39 +0100 Subject: [PATCH 55/62] Swap 5xx for 4xx else MSC4242 rooms can trigger retry backoffs server-wide ..which would be bad. --- synapse/federation/federation_server.py | 10 ++++++---- synapse/handlers/federation.py | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/synapse/federation/federation_server.py b/synapse/federation/federation_server.py index 3e051d83066..7a582ca255e 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -236,10 +236,11 @@ async def _handle_old_staged_events(self) -> None: async def _assert_not_state_dag_room(self, room_id: str, endpoint: str) -> None: room_version = await self.store.get_room_version(room_id) if room_version.msc4242_state_dags: + # Not 5xx else the server will back off from us raise SynapseError( - 500, + 400, f"{endpoint} is not implemented for MSC4242 state DAG rooms", - errcode=Codes.UNKNOWN, + errcode=Codes.UNRECOGNIZED, ) async def on_backfill_request( @@ -1167,10 +1168,11 @@ async def _on_send_membership_event( room_version = await self.store.get_room_version(room_id) if room_version.msc4242_state_dags and membership_type != Membership.JOIN: + # 4xx rather than 5xx, so the caller does not back off from us raise SynapseError( - 500, + 400, f"/send_{membership_type} is not implemented for MSC4242 state DAG rooms", - errcode=Codes.UNKNOWN, + errcode=Codes.UNRECOGNIZED, ) if await self.store.is_partial_state_room(room_id): diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index f3a024da3a3..2e5cc7c6889 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -1463,10 +1463,11 @@ async def on_invite_request( raise SynapseError(400, "The invite event did not have a state key") if room_version.msc4242_state_dags: + # 4xx rather than 5xx, so the caller does not back off from us raise SynapseError( - 500, + 400, "/invite is not implemented for MSC4242 state DAG rooms", - errcode=Codes.UNKNOWN, + errcode=Codes.UNRECOGNIZED, ) is_blocked = await self.store.is_room_blocked(event.room_id) From 0aea320a6e2d99687961d50ed09ed4a225da447a Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:48:46 +0100 Subject: [PATCH 56/62] Linting --- tests/handlers/test_federation_event.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/handlers/test_federation_event.py b/tests/handlers/test_federation_event.py index 7a7ae53e62e..c3f4be40ee8 100644 --- a/tests/handlers/test_federation_event.py +++ b/tests/handlers/test_federation_event.py @@ -1527,6 +1527,7 @@ def test_ignores_events_from_other_rooms(self) -> None: self._prepare_handler(set(), linear, {("C",): ["A", "B", "FOREIGN"]}) self._assert_fetches(linear, "C", ["A", "B"]) + class BackfilledStateDagEventTests(unittest.FederatingHomeserverTestCase): servlets = [ admin.register_servlets, From ce6a0c590ad62666e8d6fc5159f501092ff0a66b Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:50:00 +0100 Subject: [PATCH 57/62] Remove unused patch file --- x.patch | 88 --------------------------------------------------------- 1 file changed, 88 deletions(-) delete mode 100644 x.patch diff --git a/x.patch b/x.patch deleted file mode 100644 index 6fe74f2379d..00000000000 --- a/x.patch +++ /dev/null @@ -1,88 +0,0 @@ -diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py -index 52e5435793..26d74305f8 100644 ---- a/synapse/handlers/federation_event.py -+++ b/synapse/handlers/federation_event.py -@@ -270,23 +270,22 @@ class FederationEventHandler: - ) = await self._store.get_local_current_membership_for_user_in_room( - pdu.state_key, pdu.room_id - ) -- if ( -- membership == Membership.INVITE -- and membership_event_id -- and membership_event_id -- in pdu.auth_event_ids() # The invite should be in the auth events of the rescission. -- ): -+ if membership == Membership.INVITE and membership_event_id: - invite_event = await self._store.get_event( - membership_event_id, allow_none=True - ) - - # We cannot fully auth the rescission event, but we can - # check if the sender of the leave event is the same as the -- # invite. -+ # invite, and that it references the invite it rescinds. - # - # Technically, a room admin could rescind the invite, but we - # have no way of knowing who is and isn't a room admin. -- if invite_event and pdu.sender == invite_event.sender: -+ if ( -+ invite_event -+ and pdu.sender == invite_event.sender -+ and rescinds_invite(pdu, invite_event) -+ ): - # Handle the rescission event - pdu.internal_metadata.outlier = True - pdu.internal_metadata.out_of_band_membership = True -@@ -3172,6 +3171,23 @@ class FederationEventHandler: - raise SynapseError(HTTPStatus.BAD_REQUEST, "Too many auth_events") - - -+def rescinds_invite(pdu: EventBase, invite_event: EventBase) -> bool: -+ """Returns True if the given leave event references the given invite. -+ -+ We are not in the room, so we cannot authorise the leave event: the most we can do is -+ check that it names the invite we are holding. Without this an old leave event could -+ be replayed by anyone to cancel a newer invite. -+ """ -+ if supports_msc4242_state_dag(pdu): -+ # MSC4242 state DAG events don't list their auth events: they are calculated from -+ # the state DAG, which we cannot do as we are not in the room. Instead, a leave -+ # event which rescinds an invite must name the invite in its prev_state_events. -+ return invite_event.event_id in pdu.prev_state_events -+ -+ # The invite should be in the auth events of the rescission. -+ return invite_event.event_id in pdu.auth_event_ids() -+ -+ - def is_state_dag_connected(state_dag: Collection[MSC4242Event]) -> bool: - """Returns True if the given events form a connected state DAG (MSC4242). - -diff --git a/synapse/handlers/room_member.py b/synapse/handlers/room_member.py -index b73e7114c0..faa8aab7ea 100644 ---- a/synapse/handlers/room_member.py -+++ b/synapse/handlers/room_member.py -@@ -1040,6 +1040,24 @@ class RoomMemberHandler(metaclass=abc.ABCMeta): - "You cannot reject this invite", - errcode=Codes.CANNOT_LEAVE_SERVER_NOTICE_ROOM, - ) -+ -+ # If we are rescinding an invite to a user on a server which is not in the -+ # room, that server cannot authorise the leave event: it has no state DAG, -+ # so it cannot calculate the auth events which would otherwise tell it that -+ # the leave supersedes the invite it is holding. Name the invite in -+ # prev_state_events so that it can, see MSC4242. -+ if ( -+ is_state_dags -+ and prev_state_events is None -+ and old_membership == Membership.INVITE -+ and effective_membership_state == Membership.LEAVE -+ and requester.user.to_string() != target.to_string() -+ and not self.hs.is_mine(target) -+ ): -+ # `latest_event_ids` are the state DAG extremities for these rooms. -+ prev_state_events = list(latest_event_ids) -+ if old_state_id not in prev_state_events: -+ prev_state_events.append(old_state_id) - else: - if action == "kick": - raise AuthError(403, "The target user is not in the room") From 6fa25a10e87cf76af7d94d2a38de9c04a46ddc1b Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:56:20 +0200 Subject: [PATCH 58/62] Update test to reflect 4xx behaviour in /get_missing_events; more docs --- synapse/handlers/federation_event.py | 7 ++++ synapse/storage/databases/main/state.py | 5 +++ tests/federation/test_federation_server.py | 39 ++++++++++++++++------ 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 41821789d96..0ab2c87ba26 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -2325,6 +2325,13 @@ async def _calculate_state_dag_context( # If this assumption is ever violated, `compute_state_after_events` will # yell loudly about missing state groups. # + # XXX: `_get_state_group_for_event` raises `RuntimeError` for unknown events, but + # the `@cachedList` wrapper swallows it and the caller just gets partial results. + # For the time-being, Callers MUST NOT rely on this to determine missing events, + # as cached responses return None for the state group instead of raising. + # + # TODO: audit every caller, as some of them may be relying on the current behaviour. + # # In the context of partially stated rooms (faster remote room joins) # this would be part of background step verifying the entire state DAG, # so we know that we don't have the full state processed yet. To wait diff --git a/synapse/storage/databases/main/state.py b/synapse/storage/databases/main/state.py index 3e42d460848..9c5a0e57c03 100644 --- a/synapse/storage/databases/main/state.py +++ b/synapse/storage/databases/main/state.py @@ -702,6 +702,11 @@ async def _get_state_group_for_events( ) -> Mapping[str, int]: """Returns mapping event_id -> state_group. + XXX: the `@cachedList` wrapper swallows RuntimeError and the caller just gets partial results. + For the time-being, Callers MUST NOT rely on this to determine missing events, + as cached responses return None for the state group instead of raising. + TODO: audit every caller, as some of them may be relying on the current behaviour. + Raises: RuntimeError if the state is unknown at any of the given events """ diff --git a/tests/federation/test_federation_server.py b/tests/federation/test_federation_server.py index c5bcf9232c7..bba12f5860d 100644 --- a/tests/federation/test_federation_server.py +++ b/tests/federation/test_federation_server.py @@ -43,6 +43,7 @@ from synapse.util.clock import Clock from tests import unittest +from tests.server import FakeChannel from tests.test_utils.event_builders import make_test_event, make_test_pdu_event from tests.unittest import override_config @@ -439,13 +440,13 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: self.room_id, "m.room.topic", {"topic": "two"}, tok=self.local_user_token )["event_id"] - def _get_missing_events( + def _get_missing_events_channel( self, earliest_events: list[str], latest_events: list[str], walk_state_dag: bool, limit: int = 10, - ) -> list[JsonDict]: + ) -> FakeChannel: content: JsonDict = { "earliest_events": earliest_events, "latest_events": latest_events, @@ -454,11 +455,25 @@ def _get_missing_events( if walk_state_dag: content[StateDag.GET_MISSING_EVENTS_FIELD] = True - channel = self.make_signed_federation_request( + return self.make_signed_federation_request( "POST", f"/_matrix/federation/v1/get_missing_events/{self.room_id}", content=content, ) + + def _get_missing_events( + self, + earliest_events: list[str], + latest_events: list[str], + walk_state_dag: bool, + limit: int = 10, + ) -> list[JsonDict]: + channel = self._get_missing_events_channel( + earliest_events=earliest_events, + latest_events=latest_events, + walk_state_dag=walk_state_dag, + limit=limit, + ) self.assertEqual(HTTPStatus.OK, channel.code, channel.json_body) return channel.json_body["events"] @@ -510,25 +525,27 @@ def test_honours_limit(self) -> None: self.assertEqual(len(events), 2) def test_is_opt_in(self) -> None: + # the state DAG is only walked when the request asks for it... state_dag_events = self._get_missing_events( earliest_events=[], latest_events=[self.second_topic_id], limit=20, walk_state_dag=True, ) - timeline_events = self._get_missing_events( + self.assertEqual( + [ev for ev in state_dag_events if ev["type"] == "m.room.message"], [] + ) + + # ...and when it doesn't, we refuse to serve the request rather than walking + # the timeline DAG, which isn't implemented for state DAG rooms. + channel = self._get_missing_events_channel( earliest_events=[], latest_events=[self.second_topic_id], limit=20, walk_state_dag=False, ) - - self.assertEqual( - [ev for ev in state_dag_events if ev["type"] == "m.room.message"], [] - ) - self.assertNotEqual( - [ev for ev in timeline_events if ev["type"] == "m.room.message"], [] - ) + self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.json_body) + self.assertEqual(Codes.UNRECOGNIZED, channel.json_body["errcode"]) def test_walks_from_a_non_state_event(self) -> None: store = self.hs.get_datastores().main From fecc9f4a7f432c8aeca6db76e56a740535e92ba1 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:01:34 +0200 Subject: [PATCH 59/62] Ensure we build valid MSC4242 events --- synapse/events/builder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/synapse/events/builder.py b/synapse/events/builder.py index 742bb82da99..15a543cf3e2 100644 --- a/synapse/events/builder.py +++ b/synapse/events/builder.py @@ -307,6 +307,7 @@ async def build( # Auth events are removed entirely on state DAG rooms event_dict.pop("auth_events") assert prev_state_events is not None + assert len(prev_state_events) <= 20 event_dict["prev_state_events"] = prev_state_events if self.room_id is not None: event_dict["room_id"] = self.room_id From 1d2ee64a002f3d66d6f5f038eaf97d0fede8f7b1 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:06:26 +0200 Subject: [PATCH 60/62] Even more review comments --- synapse/api/constants.py | 4 ++++ synapse/events/builder.py | 10 ++++++++-- synapse/federation/federation_server.py | 9 +++++++++ synapse/handlers/federation.py | 1 + synapse/storage/controllers/state.py | 6 ++++-- synapse/storage/databases/main/event_federation.py | 5 +++-- synapse/storage/databases/main/state.py | 1 + 7 files changed, 30 insertions(+), 6 deletions(-) diff --git a/synapse/api/constants.py b/synapse/api/constants.py index bfdb3ef9266..1a6237636ad 100644 --- a/synapse/api/constants.py +++ b/synapse/api/constants.py @@ -515,3 +515,7 @@ class StateDag: Arbitrary, servers usually never get this high so it's mostly to bound the max size of a single /get_missing_events response. """ + MAX_PREV_STATE_EVENTS: Final = 20 + """ + Max number of prev_state_events allowed on an MSC4242 event as per the MSC. + """ diff --git a/synapse/events/builder.py b/synapse/events/builder.py index 15a543cf3e2..69c19f07747 100644 --- a/synapse/events/builder.py +++ b/synapse/events/builder.py @@ -24,7 +24,13 @@ import attr from signedjson.types import SigningKey -from synapse.api.constants import MAX_DEPTH, EventTypes, StickyEvent, StickyEventField +from synapse.api.constants import ( + MAX_DEPTH, + EventTypes, + StateDag, + StickyEvent, + StickyEventField, +) from synapse.api.room_versions import ( KNOWN_EVENT_FORMAT_VERSIONS, EventFormatVersions, @@ -307,7 +313,7 @@ async def build( # Auth events are removed entirely on state DAG rooms event_dict.pop("auth_events") assert prev_state_events is not None - assert len(prev_state_events) <= 20 + assert len(prev_state_events) <= StateDag.MAX_PREV_STATE_EVENTS event_dict["prev_state_events"] = prev_state_events if self.room_id is not None: event_dict["room_id"] = self.room_id diff --git a/synapse/federation/federation_server.py b/synapse/federation/federation_server.py index 7a582ca255e..06b93dccc11 100644 --- a/synapse/federation/federation_server.py +++ b/synapse/federation/federation_server.py @@ -286,6 +286,14 @@ async def on_timestamp_to_event_request( origin_host, _ = parse_server_name(origin) await self.check_server_matches_acl(origin_host, room_id) + if (await self.store.get_room_version(room_id)).msc4242_state_dags: + # TODO: we currently don't support /timestamp_to_event in MSC4242 rooms. + raise SynapseError( + 404, + "This endpoint is not yet implemented in MSC4242 rooms", + errcode=Codes.NOT_FOUND, + ) + # We only try to fetch data from the local database event_id = await self.store.get_event_id_for_timestamp( room_id, timestamp, direction @@ -1354,6 +1362,7 @@ async def on_get_missing_events( origin, room_id, earliest_events, latest_events, limit ) else: + # TODO: currently we only handle /send_join and connected events in /send for state DAG rooms await self._assert_not_state_dag_room(room_id, "/get_missing_events") missing_events = await self.handler.on_get_missing_events( origin, room_id, earliest_events, latest_events, limit diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 2e5cc7c6889..fab47968fa5 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -255,6 +255,7 @@ async def maybe_backfill( True if we actually tried to backfill something, otherwise False. """ if (await self.store.get_room_version(room_id)).msc4242_state_dags: + # TODO: we currently don't support backfilling in MSC4242 rooms. return False # Starting the processing time here so we can include the room backfill diff --git a/synapse/storage/controllers/state.py b/synapse/storage/controllers/state.py index 4b3c978ccdc..a6226c9dbdb 100644 --- a/synapse/storage/controllers/state.py +++ b/synapse/storage/controllers/state.py @@ -535,8 +535,10 @@ async def get_state_group_for_events( RuntimeError if we don't have a state group for one or more of the events (ie. they are outliers or unknown) AND the lookup reached the database. - Callers MUST NOT rely on this to determine missing events, as cached responses - return None for the state group instead of raising. + XXX: `_get_state_group_for_events` raises `RuntimeError` for unknown events, but + the `@cachedList` wrapper swallows it and the caller just gets partial results. + For the time-being, Callers MUST NOT rely on this to determine missing events, + as cached responses return None for the state group instead of raising. TODO: audit every caller, as some of them may be relying on the current behaviour. """ diff --git a/synapse/storage/databases/main/event_federation.py b/synapse/storage/databases/main/event_federation.py index 87748c40725..0130d0e5130 100644 --- a/synapse/storage/databases/main/event_federation.py +++ b/synapse/storage/databases/main/event_federation.py @@ -34,7 +34,7 @@ import attr from prometheus_client import Counter, Gauge -from synapse.api.constants import MAX_DEPTH +from synapse.api.constants import MAX_DEPTH, StateDag from synapse.api.errors import StoreError from synapse.api.room_versions import EventFormatVersions, RoomVersion from synapse.events import EventBase, make_event_from_dict @@ -1732,7 +1732,8 @@ async def get_state_dag_extremities(self, room_id: str) -> frozenset[str]: retcol="event_id", desc="get_state_dag_extremities", ) - return frozenset(event_ids) + # FIXME: choose more intelligently + return frozenset(event_ids[: StateDag.MAX_PREV_STATE_EVENTS]) async def get_min_depth(self, room_id: str) -> int | None: """For the given room, get the minimum depth we have seen for it.""" diff --git a/synapse/storage/databases/main/state.py b/synapse/storage/databases/main/state.py index 9c5a0e57c03..bc8a902846c 100644 --- a/synapse/storage/databases/main/state.py +++ b/synapse/storage/databases/main/state.py @@ -705,6 +705,7 @@ async def _get_state_group_for_events( XXX: the `@cachedList` wrapper swallows RuntimeError and the caller just gets partial results. For the time-being, Callers MUST NOT rely on this to determine missing events, as cached responses return None for the state group instead of raising. + TODO: audit every caller, as some of them may be relying on the current behaviour. Raises: From a919eedfd3772f10dbe0dd4ba2645cac712e2986 Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:28:58 +0200 Subject: [PATCH 61/62] Remove comment as it was moved elsewhere --- synapse/handlers/federation_event.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 0ab2c87ba26..41821789d96 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -2325,13 +2325,6 @@ async def _calculate_state_dag_context( # If this assumption is ever violated, `compute_state_after_events` will # yell loudly about missing state groups. # - # XXX: `_get_state_group_for_event` raises `RuntimeError` for unknown events, but - # the `@cachedList` wrapper swallows it and the caller just gets partial results. - # For the time-being, Callers MUST NOT rely on this to determine missing events, - # as cached responses return None for the state group instead of raising. - # - # TODO: audit every caller, as some of them may be relying on the current behaviour. - # # In the context of partially stated rooms (faster remote room joins) # this would be part of background step verifying the entire state DAG, # so we know that we don't have the full state processed yet. To wait From bfb98add36f5d2066d17dd95de22b5a94008c1db Mon Sep 17 00:00:00 2001 From: Kegan Dougal <7190048+kegsay@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:25:49 +0200 Subject: [PATCH 62/62] Rework where the max check lives to ensure we don't drop forward extremities on persistence --- synapse/events/builder.py | 5 ++- synapse/handlers/federation_event.py | 36 +++++++++++-------- .../databases/main/event_federation.py | 5 ++- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/synapse/events/builder.py b/synapse/events/builder.py index 69c19f07747..d7369da3ed1 100644 --- a/synapse/events/builder.py +++ b/synapse/events/builder.py @@ -163,6 +163,10 @@ async def build( assert self.room_version.msc4242_state_dags if self.room_version.msc4242_state_dags: assert prev_state_events is not None + # FIXME: choose more intelligently + prev_state_events = sorted(prev_state_events)[ + : StateDag.MAX_PREV_STATE_EVENTS + ] if self.room_id: state_ids = await self._state.compute_state_after_events( self.room_id, @@ -313,7 +317,6 @@ async def build( # Auth events are removed entirely on state DAG rooms event_dict.pop("auth_events") assert prev_state_events is not None - assert len(prev_state_events) <= StateDag.MAX_PREV_STATE_EVENTS event_dict["prev_state_events"] = prev_state_events if self.room_id is not None: event_dict["room_id"] = self.room_id diff --git a/synapse/handlers/federation_event.py b/synapse/handlers/federation_event.py index 41821789d96..4eca7f2104d 100644 --- a/synapse/handlers/federation_event.py +++ b/synapse/handlers/federation_event.py @@ -41,6 +41,7 @@ Membership, RejectedReason, RoomEncryptionAlgorithms, + StateDag, ) from synapse.api.errors import ( AuthError, @@ -558,19 +559,23 @@ async def process_remote_join( if room_version.identifier != room_version_id: raise SynapseError(400, "Room version mismatch") - if room_version.msc4242_state_dags and not is_state_dag_connected( - [ - ev - for ev in itertools.chain(state, [event]) - if supports_msc4242_state_dag(ev) - ] - ): - # We should have been given a connected state DAG. If there is a gap in it we - # cannot calculate the state, so the response is invalid and we refuse the join. - raise SynapseError( - 502, - "Unable to join because the remote server passed back a state DAG that is not connected (invalid)", - ) + # Additional validation is performed on the returned events prior to persisting in MSC4242 rooms. + if room_version.msc4242_state_dags: + # The join event was built by the remote server, not us. We need to make sure it is valid. + self._sanity_check_event(event) + if not is_state_dag_connected( + [ + ev + for ev in itertools.chain(state, [event]) + if supports_msc4242_state_dag(ev) + ] + ): + # We should have been given a connected state DAG. If there is a gap in it we + # cannot calculate the state, so the response is invalid and we refuse the join. + raise SynapseError( + 502, + "Unable to join because the remote server passed back a state DAG that is not connected (invalid)", + ) # persist the auth chain and state events. # @@ -3002,7 +3007,10 @@ def _sanity_check_event(self, ev: EventBase) -> None: ) raise SynapseError(HTTPStatus.BAD_REQUEST, "Too many prev_events") - if supports_msc4242_state_dag(ev) and len(ev.prev_state_events) > 20: + if ( + supports_msc4242_state_dag(ev) + and len(ev.prev_state_events) > StateDag.MAX_PREV_STATE_EVENTS + ): logger.warning( "Rejecting event %s which has %i prev_state_events", ev.event_id, diff --git a/synapse/storage/databases/main/event_federation.py b/synapse/storage/databases/main/event_federation.py index 0130d0e5130..87748c40725 100644 --- a/synapse/storage/databases/main/event_federation.py +++ b/synapse/storage/databases/main/event_federation.py @@ -34,7 +34,7 @@ import attr from prometheus_client import Counter, Gauge -from synapse.api.constants import MAX_DEPTH, StateDag +from synapse.api.constants import MAX_DEPTH from synapse.api.errors import StoreError from synapse.api.room_versions import EventFormatVersions, RoomVersion from synapse.events import EventBase, make_event_from_dict @@ -1732,8 +1732,7 @@ async def get_state_dag_extremities(self, room_id: str) -> frozenset[str]: retcol="event_id", desc="get_state_dag_extremities", ) - # FIXME: choose more intelligently - return frozenset(event_ids[: StateDag.MAX_PREV_STATE_EVENTS]) + return frozenset(event_ids) async def get_min_depth(self, room_id: str) -> int | None: """For the given room, get the minimum depth we have seen for it."""