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

Out-of-band membership events are outliers: they never enter the
current state delta stream, and the archived-room branch of sync
deliberately returns no state for them. Clients using MSC4222's
state_after do not apply timeline events to state, so without this they
never learn that an invite rejection / knock rescission / knock denial
changed their membership, and e.g. Element Web keeps showing a knock as
pending after a federated deny.

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-13 22:26:19 +01:00
co-authored by Claude Fable 5
parent 748f3035d5
commit 6d36d8f6ed
3 changed files with 87 additions and 8 deletions
+1
View File
@@ -0,0 +1 @@
Reflect out-of-band membership events (invite rejections, knock rescissions and denials) in `state_after` (MSC4222) on `/sync`, so that clients which no longer apply timeline events to room state learn of the membership change.
+20
View File
@@ -2870,6 +2870,26 @@ class SyncHandler:
# An out of band room won't have any state changes.
state = {}
# ...however, clients using `state_after` (MSC4222) no longer
# apply timeline events to room state, so we must reflect the
# out-of-band membership event itself in `state_after`:
# otherwise the client never learns of the membership change
# (e.g. an invite rejection, or a knock rescission or denial).
if sync_config.use_state_after:
for timeline_event in batch.events:
if (
timeline_event.event.type == EventTypes.Member
and timeline_event.event.state_key
== sync_config.user.to_string()
and timeline_event.event.internal_metadata.is_out_of_band_membership()
):
state[
(
timeline_event.event.type,
timeline_event.event.state_key,
)
] = timeline_event.event
summary: JsonDict | None = {}
# we include a summary in room responses when we're lazy loading
@@ -28,14 +28,13 @@ from twisted.internet.testing import MemoryReactor
from synapse.api.constants import EventContentFields, EventTypes, Membership
from synapse.api.room_versions import RoomVersion, RoomVersions
from synapse.events import EventBase
from synapse.events import builder
from synapse.events import EventBase, builder
from synapse.events.snapshot import EventContext
from synapse.events.utils import strip_event
from synapse.http.matrixfederationclient import ByteParser
from synapse.http.types import QueryParams
from synapse.rest import admin
from synapse.rest.client import knock, login, room
from synapse.rest.client import knock, login, room, sync
from synapse.server import HomeServer
from synapse.types import JsonDict
from synapse.util.clock import Clock
@@ -75,11 +74,18 @@ class KnockViaServerTestCase(unittest.FederatingHomeserverTestCase):
knock.register_servlets,
room.register_servlets,
login.register_servlets,
sync.register_servlets,
]
def default_config(self) -> JsonDict:
conf = super().default_config()
conf["experimental_features"] = {"msc4233_enabled": True}
conf["experimental_features"] = {
"msc4233_enabled": True,
# For the state_after test: out-of-band leaves must be reflected
# in `state_after` for clients that no longer apply timeline
# events to state.
"msc4222_enabled": True,
}
return conf
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
@@ -417,6 +423,60 @@ class KnockViaServerTestCase(unittest.FederatingHomeserverTestCase):
break
time.sleep(0.1)
def test_denied_knock_in_state_after(self) -> None:
"""The out-of-band leave retracting the knock is reflected in
`state_after` (MSC4222) on sync: it is an outlier, so it never enters
the current state delta stream, and clients using `state_after` do
not apply timeline events to state."""
knock_result = self._knock_on_remote_room()
# Sync up to just after the knock.
channel = self.make_request(
"GET",
"/_matrix/client/v3/sync?timeout=0&org.matrix.msc4222.use_state_after=true",
access_token=knock_result.local_user1_tok,
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
since_token = channel.json_body["next_batch"]
self._send_denial_over_federation(
knock_result,
auth_events=[
knock_result.room_create_event.event_id,
knock_result.knock_event_id,
],
)
with test_timeout(3, "Denial of the knock was not processed"):
while True:
membership, _ = self.get_success(
self.store.get_local_current_membership_for_user_in_room(
knock_result.local_user1_id, knock_result.remote_room_id
)
)
if membership == Membership.LEAVE:
break
time.sleep(0.1)
channel = self.make_request(
"GET",
f"/_matrix/client/v3/sync?timeout=0&org.matrix.msc4222.use_state_after=true&since={since_token}",
access_token=knock_result.local_user1_tok,
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
leave_room = channel.json_body["rooms"]["leave"][knock_result.remote_room_id]
state_after_events = leave_room["org.matrix.msc4222.state_after"]["events"]
self.assertTrue(
any(
e["type"] == EventTypes.Member
and e["state_key"] == knock_result.local_user1_id
and e["content"]["membership"] == Membership.LEAVE
for e in state_after_events
),
leave_room,
)
def test_denied_knock_ignored_without_knock_in_auth_events(self) -> None:
"""A leave event for our knocked user which does not reference the
knock in its auth events is ignored."""
@@ -495,7 +555,7 @@ class DenyKnockFederationSendTestCase(unittest.FederatingHomeserverTestCase):
def test_deny_sends_leave_to_knocking_server(self) -> None:
"""Kicking a remote user whose membership is knock sends the leave
event to their (otherwise uninvolved) server."""
user_id = self.register_user("u1", "pass")
self.register_user("u1", "pass")
user_token = self.login("u1", "pass")
fake_knocking_user_id = f"@user:{self.OTHER_SERVER_NAME}"
@@ -585,9 +645,7 @@ class DenyKnockFederationSendTestCase(unittest.FederatingHomeserverTestCase):
while True:
leave_pdus = [
pdu
for pdu in sent_pdus_by_destination.get(
self.OTHER_SERVER_NAME, []
)
for pdu in sent_pdus_by_destination.get(self.OTHER_SERVER_NAME, [])
if pdu.get("type") == EventTypes.Member
and pdu.get("state_key") == fake_knocking_user_id
and pdu.get("content", {}).get("membership") == Membership.LEAVE