Reflect out-of-band leaves in state_after on /sync

Out-of-band membership events are outliers. They never enter the current
state delta stream, so `_generate_room_entry` returns an empty `state` for
the archived room they belong to.

MSC4222 says clients "MUST only update their local state using
`state_after` and NOT consider the events that appear in the timeline". A
client on `state_after` therefore never learns that the user rejected a
remote invite or rescinded a knock, and Element Web keeps showing the knock
as pending.

This copies the user's out-of-band membership event into `state_after`.
It reads `room_builder.events`, the unfiltered timeline, as sliding sync
does in `get_room_sync_data`: a timeline filter that drops `m.room.member`
still gets the leave. The event is already in memory, so sync makes no new
query.

#20081 and #20314 fixed `state_after` for a leave that empties the room of
local users by recovering state in
`_compute_state_delta_for_incremental_sync`. An out-of-band leave has no
state group and never reaches that function, because `_generate_room_entry`
skips state computation for out-of-band rooms.

A timeline filter that empties the batch still gets no room at all: the
empty-batch shortcut returns first. The FIXME in `tests/handlers/test_sync.py`
records that limitation.

Co-authored-by: Quentin Gliech <quenting@element.io>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
authored and Quentin Gliech committed 2026-10-30 10:15:27 +01:00
1 parent ca28da9663
commit 489e627b59
2 files changed
+20

No files matched your search

+1
View File
@@ -0,0 +1 @@
Fix a bug where a client using `state_after` ([MSC4222](https://github.com/matrix-org/matrix-spec-proposals/pull/4222)) would keep showing a room as invited or knocked after the user rejected the invite or rescinded the knock.
+19
View File
@@ -3570,6 +3570,25 @@ class SyncHandler:
# An out of band room won't have any state changes.
state = {}
# MSC4222 clients do not apply timeline events to room state, so
# the out-of-band membership has to appear in `state_after` or they
# never learn that the user rejected the invite or rescinded the
# knock. Sliding sync does the same in `get_room_sync_data`.
#
# `events` is unfiltered, so a client filter that hides the
# membership event still gets it in `state_after`. A filter that
# empties the timeline batch gets no room at all, as the shortcut
# above returns first (see the FIXME in
# `tests/handlers/test_sync.py`).
if sync_config.use_state_after:
for oob_event in events or ():
if (
oob_event.type == EventTypes.Member
and oob_event.state_key == sync_config.user.to_string()
and oob_event.internal_metadata.is_out_of_band_membership()
):
state[(oob_event.type, oob_event.state_key)] = oob_event
summary: JsonDict | None = {}
# we include a summary in room responses when we're lazy loading