Don't 500 /sync when an always-included event has no state

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uk8aPxHn3BHCe52L226jdG
This commit is contained in:
Matthew Hodgson
2026-07-14 18:35:44 +01:00
co-authored by Claude Fable 5
parent ec6c53e7df
commit ed885f590a
3 changed files with 54 additions and 17 deletions
+1
View File
@@ -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.
+28 -17
View File
@@ -196,21 +196,17 @@ async def filter_and_transform_events_for_client(
# 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 @@ async def filter_and_transform_events_for_client(
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
+25
View File
@@ -665,6 +665,31 @@ class FilterEventsOutOfBandEventsForClientTestCase(
[],
)
# ... 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,