From ed885f590a20b276b8107e3eba97dbfdfcfd1f95 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 14 Jul 2026 18:35:44 +0100 Subject: [PATCH 1/2] Don't 500 /sync when an always-included event has no state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filter_and_transform_events_for_client computes the requesting user's membership (MSC4115) from the state after each event, fetched only for non-outlier events. An event let through by always_include_ids therefore has no state to look in if it is flagged as an outlier — which happens when an out-of-band membership event is later de-outliered (we are also in the room and received it over federation) and enters the current state (sync's always_include_ids), while the copy being filtered (e.g. from a worker's not-yet-invalidated cache) still carries the outlier flag. This hit the 'unreachable' raise, failing the whole /sync with a 500. Return the event without a membership annotation instead (MSC4115 permits omitting it), and downgrade the neighbouring call-invite assert with the same reachability hole to a guard. Fixes #19858. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Uk8aPxHn3BHCe52L226jdG --- changelog.d/19858.bugfix | 1 + synapse/visibility.py | 45 +++++++++++++++++++++++++--------------- tests/test_visibility.py | 25 ++++++++++++++++++++++ 3 files changed, 54 insertions(+), 17 deletions(-) create mode 100644 changelog.d/19858.bugfix diff --git a/changelog.d/19858.bugfix b/changelog.d/19858.bugfix new file mode 100644 index 00000000000..c2af8dd7838 --- /dev/null +++ b/changelog.d/19858.bugfix @@ -0,0 +1 @@ +Fix a bug where `/sync` could return a 500 error ("Missing state for event that is not user's own membership") when an out-of-band membership event, since de-outliered into the room's current state, appeared in a room timeline. diff --git a/synapse/visibility.py b/synapse/visibility.py index 4eeba14a6c5..5b8e180f9ac 100644 --- a/synapse/visibility.py +++ b/synapse/visibility.py @@ -196,21 +196,17 @@ def allowed(event: EventBase) -> FilteredEvent | None: # Filter out call invites in public rooms, as this would potentially # ring a lot of users. if event.type == EventTypes.CallInvite and not event.is_state(): - # `state_after_event` should only be None if the event is an outlier, - # and earlier code should filter out outliers entirely. - # - # In addition, we only create outliers locally for out-of-band - # invite rejections, invites received over federation, or state - # events needed to authorise other events. None of this applies to - # call invites. - assert state_after_event is not None - - room_join_rules = state_after_event.get((EventTypes.JoinRules, "")) - if ( - room_join_rules is not None - and room_join_rules.content.get("join_rule") == JoinRules.PUBLIC - ): - return None + # `state_after_event` should only be None if the event is an outlier + # (or our copy of the event is stale and still flagged as one, see + # below); in either case we can't check the join rules, so let the + # event through. + if state_after_event is not None: + room_join_rules = state_after_event.get((EventTypes.JoinRules, "")) + if ( + room_join_rules is not None + and room_join_rules.content.get("join_rule") == JoinRules.PUBLIC + ): + return None # Annotate the event with the user's membership after the event. # @@ -224,8 +220,23 @@ def allowed(event: EventBase) -> FilteredEvent | None: elif state_after_event is not None: user_membership_event = state_after_event.get((EventTypes.Member, user_id)) else: - # unreachable! - raise Exception("Missing state for event that is not user's own membership") + # We have no state for this event: it was flagged as an outlier + # when we fetched the state above, but `always_include_ids` let it + # through anyway. This can happen when an out-of-band membership + # event (e.g. an invite, or a rejection of one, received while not + # in the room) is later de-outliered — because we are also in the + # room and received it over federation — and enters the current + # state, while the copy of the event we are working with (e.g. from + # a worker's not-yet-invalidated cache) still carries the outlier + # flag. We don't know the user's membership at this point in the + # DAG, which MSC4115 permits — return the event unannotated rather + # than failing the whole request. + logger.info( + "filter_and_transform_events_for_client: no state for always-included" + " outlier %s; returning it without a membership annotation", + event.event_id, + ) + return FilteredEvent(event=filtered, membership=None) user_membership = ( user_membership_event.membership diff --git a/tests/test_visibility.py b/tests/test_visibility.py index 9a5efbdd399..257e6feeda4 100644 --- a/tests/test_visibility.py +++ b/tests/test_visibility.py @@ -665,6 +665,31 @@ def test_out_of_band_invite_rejection(self) -> None: [], ) + # ... unless the events are in `always_include_ids`. This mirrors what + # happens when an out-of-band membership event is later de-outliered + # (because we are also in the room and received it over federation) and + # enters the current state — which sync passes as `always_include_ids` + # — while the copy of the event being filtered still carries the + # outlier flag. We can't compute the requesting user's membership at + # such an event (we have no state for it), but it must be let through + # without a membership annotation rather than failing the whole + # request. + # + # Regression test for https://github.com/element-hq/synapse/issues/19858. + filtered_events = self.get_success( + filter_and_transform_events_for_client( + self.hs.get_storage_controllers(), + "@other:test", + [invite_event, reject_event], + always_include_ids=frozenset({invite_event_id}), + ) + ) + self.assertEqual( + [e.event.event_id for e in filtered_events], + [invite_event_id], + ) + self.assertEqual([e.membership for e in filtered_events], [None]) + async def inject_visibility_event( hs: HomeServer, From c53b36ca3cef6a7e812811a7b1824a68c8f07699 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Tue, 14 Jul 2026 18:41:41 +0100 Subject: [PATCH 2/2] changelog --- changelog.d/{19858.bugfix => 19960.bugfix} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{19858.bugfix => 19960.bugfix} (100%) diff --git a/changelog.d/19858.bugfix b/changelog.d/19960.bugfix similarity index 100% rename from changelog.d/19858.bugfix rename to changelog.d/19960.bugfix