From a1ff97837c08df6aa79449ffbf012fcd592e5f55 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 3 Mar 2026 11:09:03 -0600 Subject: [PATCH 1/2] Add MSC4222 `/sync` `state_after` test for initial sync lazy-loading room members (#842) This is by no means an exhaustive test suite. This test was simply used to try to reproduce https://github.com/element-hq/synapse/issues/19455#issuecomment-3890623384 Synapse fix: https://github.com/element-hq/synapse/pull/19460 --- client/sync.go | 10 ++ tests/msc4222/main_test.go | 11 +++ tests/msc4222/msc4222_test.go | 175 ++++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 tests/msc4222/main_test.go create mode 100644 tests/msc4222/msc4222_test.go diff --git a/client/sync.go b/client/sync.go index 7939d944..701f73af 100644 --- a/client/sync.go +++ b/client/sync.go @@ -39,6 +39,9 @@ type SyncReq struct { // since will be returned. // By default, this is false. FullState bool + // Controls whether to set MSC422 `use_state_after` request parameter to get + // `state_after` in the reponse (alternative to `state`). + UseStateAfter bool // Controls whether the client is automatically marked as online by polling this API. If this // parameter is omitted then the client is automatically marked as online when it uses this API. // Otherwise if the parameter is set to “offline” then the client is not marked as being online @@ -173,6 +176,13 @@ func (c *CSAPI) Sync(t ct.TestLike, syncReq SyncReq) (gjson.Result, *http.Respon if syncReq.FullState { query["full_state"] = []string{"true"} } + if syncReq.UseStateAfter { + // The spec is already stabilized + query["use_state_after"] = []string{"true"} + // FIXME: Some implementations haven't stabilized yet (Synapse) so we'll keep this + // here until then. + query["org.matrix.msc4222.use_state_after"] = []string{"true"} + } if syncReq.SetPresence != "" { query["set_presence"] = []string{syncReq.SetPresence} } diff --git a/tests/msc4222/main_test.go b/tests/msc4222/main_test.go new file mode 100644 index 00000000..c88846b2 --- /dev/null +++ b/tests/msc4222/main_test.go @@ -0,0 +1,11 @@ +package tests + +import ( + "testing" + + "github.com/matrix-org/complement" +) + +func TestMain(m *testing.M) { + complement.TestMain(m, "msc4222") +} diff --git a/tests/msc4222/msc4222_test.go b/tests/msc4222/msc4222_test.go new file mode 100644 index 00000000..8059027c --- /dev/null +++ b/tests/msc4222/msc4222_test.go @@ -0,0 +1,175 @@ +package tests + +import ( + "maps" + "slices" + "testing" + + "github.com/matrix-org/complement" + "github.com/matrix-org/complement/client" + "github.com/matrix-org/complement/helpers" + "github.com/matrix-org/complement/should" + "github.com/tidwall/gjson" +) + +func TestSync(t *testing.T) { + deployment := complement.Deploy(t, 1) + defer deployment.Destroy(t) + alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice"}) + bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob"}) + + t.Run("parallel", func(t *testing.T) { + // When lazy-loading room members is enabled, for a public room, the `state_after` + // in an initial sync request should include membership from every `sender` in the + // `timeline` + // + // We're specifically testing the scenario where a new "DM" is created and the other person + // joins without speaking yet. + t.Run("Initial sync with lazy-loading room members -> public room `state_after` includes all members from timeline", func(t *testing.T) { + t.Parallel() + + // Alice creates a room + roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "public_chat"}) + alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID)) + + // Bob joins the room + bob.MustJoinRoom(t, roomID, nil) + + // Wait for Bob's join to be seen by Alice's sync (this is not necessarily instant) + alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) + + // Ensure `state_after` looks correct + expectedSendersFromTimeline := []string{ alice.UserID, bob.UserID } + syncFilter := `{ + "room": { + "timeline": { "limit": 20 }, + "state": { "lazy_load_members": true } + } + }` + testInitialSyncStateAfterIncludesTimelineSenders(t, alice, roomID, expectedSendersFromTimeline, syncFilter) + }) + + // When lazy-loading room members is enabled, for a private room, the `state_after` + // in an initial sync request should include membership from every `sender` in the + // `timeline` + // + // We're specifically testing the scenario where a new "DM" is created and the other person + // joins without speaking yet. + t.Run("Initial sync with lazy-loading room members -> private room `state_after` includes all members from timeline", func(t *testing.T) { + t.Parallel() + + // Alice creates a room + roomID := alice.MustCreateRoom(t, map[string]interface{}{"preset": "private_chat"}) + alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID)) + + // Alice invites Bob + alice.MustInviteRoom(t, roomID, bob.UserID) + + // Wait for Bob to get the invite + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + + // Bob joins the room + bob.MustJoinRoom(t, roomID, nil) + + // Wait for Bob's join to be seen by Alice's sync (this is not necessarily instant) + alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) + + // Ensure `state_after` looks correct + expectedSendersFromTimeline := []string{ alice.UserID, bob.UserID } + syncFilter := `{ + "room": { + "timeline": { "limit": 20 }, + "state": { "lazy_load_members": true } + } + }` + testInitialSyncStateAfterIncludesTimelineSenders(t, alice, roomID, expectedSendersFromTimeline, syncFilter) + }) + }) +} + + +// The `state_after` in an initial sync request should at-least include membership from +// every `sender` in the `timeline`. +func testInitialSyncStateAfterIncludesTimelineSenders( + t *testing.T, + syncingUser *client.CSAPI, + roomID string, + expectedSendersFromTimeline []string, + syncFilter string, +) { + t.Helper() + + // `syncingUser` makes an initial sync request with lazy-loading members enabled + // + // The spec says `lazy_load_members` is valid field for both `timeline` and + // `state` but as far as I can tell, only makes sense for `state` and that's + // what Synapse keys off of. + res, _ := syncingUser.MustSync(t, client.SyncReq{UseStateAfter: true, Filter: syncFilter}) + joinedRoomRes := res.Get("rooms.join." + client.GjsonEscape(roomID)) + if !joinedRoomRes.Exists() { + t.Fatalf("Unable to find roomID=%s in the join part of the sync response: %s", roomID, res) + } + + // Collect the senders of all the time timeline events. + roomTimelineRes := joinedRoomRes.Get("timeline.events"); + if !roomTimelineRes.IsArray() { + t.Fatalf("Timeline events is not an array (found %s) %s", roomTimelineRes.Type.String(), res) + } + sendersFromTimeline := make(map[string]struct{}, 0) + for _, event := range roomTimelineRes.Array() { + sendersFromTimeline[event.Get("sender").Str] = struct{}{} + } + // We expect to see timeline events from `expectedSendersFromTimeline` + err := should.ContainSubset( + slices.Collect(maps.Keys(sendersFromTimeline)), + expectedSendersFromTimeline, + ) + if err != nil { + t.Fatalf( + "Expected to see timeline events from (%s) but only saw %s. " + + "Got error: %s. join part of the sync response: %s", + expectedSendersFromTimeline, + slices.Collect(maps.Keys(sendersFromTimeline)), + err.Error(), + res, + ) + } + + // Collect the `m.room.membership` from `state_after` + // + // Try looking up the stable variant `state_after` first, then fallback to the + // unstable version + roomStateAfterResStable := joinedRoomRes.Get("state_after.events"); + roomStateAfterResUnstable := joinedRoomRes.Get("org\\.matrix\\.msc4222\\.state_after.events"); + var roomStateAfterRes gjson.Result + if roomStateAfterResStable.Exists() { + roomStateAfterRes = roomStateAfterResStable + } else if roomStateAfterResUnstable.Exists() { + roomStateAfterRes = roomStateAfterResUnstable + } + // Sanity check syntax + if !roomStateAfterRes.IsArray() { + t.Fatalf("state_after events is not an array (found %s) %s", roomStateAfterRes.Type.String(), res) + } + membershipFromState := make(map[string]struct{}, 0) + for _, event := range roomStateAfterRes.Array() { + if event.Get("type").Str == "m.room.member" { + membershipFromState[event.Get("sender").Str] = struct{}{} + } + } + // We should see membership state from every `sender` in the `timeline`. + err = should.ContainSubset( + slices.Collect(maps.Keys(membershipFromState)), + slices.Collect(maps.Keys(sendersFromTimeline)), + ) + if err != nil { + t.Fatalf( + "Expected to see membership state (%s) from every sender in the timeline (%s). " + + "Got error: %s. join part of the sync response: %s", + slices.Collect(maps.Keys(membershipFromState)), + slices.Collect(maps.Keys(sendersFromTimeline)), + err.Error(), + res, + ) + } +} From 35b1745de7e82ed5c36a86a1d043a0aa7c1f95ac Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Wed, 4 Mar 2026 12:34:03 -0500 Subject: [PATCH 2/2] MSC4140: drop delayed state interference tests (#845) Don't test that a user's delayed state events are cancelled by a more recent state event from a different user. That behaviour [has been delegated to an "alternative"] (https://github.com/matrix-org/matrix-spec-proposals/pull/4140/changes/3efecea123eef4d672daa67b76b75a91899fed90) in the delayed event MSC4140 for some time now, and may be removed from Synapse ([Synapse PR](https://github.com/element-hq/synapse/pull/19495)) --- tests/msc4140/delayed_event_test.go | 79 ----------------------------- 1 file changed, 79 deletions(-) diff --git a/tests/msc4140/delayed_event_test.go b/tests/msc4140/delayed_event_test.go index 3e59cf20..4384411b 100644 --- a/tests/msc4140/delayed_event_test.go +++ b/tests/msc4140/delayed_event_test.go @@ -360,85 +360,6 @@ func TestDelayedEvents(t *testing.T) { }) }) - t.Run("delayed state is not cancelled by new state from the same user", func(t *testing.T) { - var res *http.Response - - stateKey := "to_not_be_cancelled_by_same_user" - - defer cleanupDelayedEvents(t, user) - - setterKey := "setter" - setterExpected := "on_timeout" - user.MustDo( - t, - "PUT", - getPathForState(roomID, eventType, stateKey), - client.WithJSONBody(t, map[string]interface{}{ - setterKey: setterExpected, - }), - getDelayQueryParam("900"), - ) - matchDelayedEvents(t, user, delayedEventsNumberEqual(1)) - - user.MustDo( - t, - "PUT", - getPathForState(roomID, eventType, stateKey), - client.WithJSONBody(t, map[string]interface{}{ - setterKey: "manual", - }), - ) - matchDelayedEvents(t, user, delayedEventsNumberEqual(1)) - - time.Sleep(1 * time.Second) - res = user.MustDo(t, "GET", getPathForState(roomID, eventType, stateKey)) - must.MatchResponse(t, res, match.HTTPResponse{ - JSON: []match.JSON{ - match.JSONKeyEqual(setterKey, setterExpected), - }, - }) - }) - - t.Run("delayed state is cancelled by new state from another user", func(t *testing.T) { - var res *http.Response - - stateKey := "to_be_cancelled_by_other_user" - - defer cleanupDelayedEvents(t, user) - defer cleanupDelayedEvents(t, user2) - - setterKey := "setter" - user.MustDo( - t, - "PUT", - getPathForState(roomID, eventType, stateKey), - client.WithJSONBody(t, map[string]interface{}{ - setterKey: "on_timeout", - }), - getDelayQueryParam("900"), - ) - matchDelayedEvents(t, user, delayedEventsNumberEqual(1)) - - setterExpected := "manual" - user2.MustDo( - t, - "PUT", - getPathForState(roomID, eventType, stateKey), - client.WithJSONBody(t, map[string]interface{}{ - setterKey: setterExpected, - }), - ) - matchDelayedEvents(t, user, delayedEventsNumberEqual(0)) - - time.Sleep(1 * time.Second) - res = user.MustDo(t, "GET", getPathForState(roomID, eventType, stateKey)) - must.MatchResponse(t, res, match.HTTPResponse{ - JSON: []match.JSON{ - match.JSONKeyEqual(setterKey, setterExpected), - }, - }) - }) - t.Run("delayed state events are kept on server restart", func(t *testing.T) { // Spec cannot enforce server restart behaviour runtime.SkipIf(t, runtime.Dendrite, runtime.Conduit, runtime.Conduwuit)