Skip to content
Merged
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 73 additions & 22 deletions tests/msc3902/federation_room_join_partial_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,15 @@ func (s *server) AddEDUHandler(eduHandler func(gomatrixserverlib.EDU) bool) func
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For reference, can you paste the full test run flake?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/element-hq/synapse/actions/runs/27136501582/job/80090514774?pr=19832

📦 github.com/matrix-org/complement/tests/msc3902
2026/06/08 12:22:24 config: &{BaseImageURI:localhost/complement-synapse DebugLoggingEnabled:false AlwaysPrintServerLogs:false EnvVarsPropagatePrefix:PASS_ SpawnHSTimeout:30s ContainerCPUCores:0 ContainerMemoryBytes:0 KeepBlueprints:[] HostMounts:[] BaseImageURIs:map[] PackageNamespace:msc3902 CACertificate:0xc000964c08 CAPrivateKey:0xc000804380 BestEffort:false HostnameRunningComplement:host.docker.internal EnableDirtyRuns:true HSPortBindingIP:127.0.0.1 PostTestScript:}
❌ TestPartialStateJoin (1m56.99s)
❌ TestPartialStateJoin/Outgoing_device_list_updates (19.95s)
❌ TestPartialStateJoin/Outgoing_device_list_updates/Device_list_updates_reach_incorrectly_kicked_servers_once_partial_state_join_completes (4.2s)
      client.go:860: [CSAPI] POST hs1/_matrix/client/v3/register => 200 OK (15.620036ms)
      client.go:860: [CSAPI] GET hs1/_matrix/client/v3/capabilities => 200 OK (3.801544ms)
      server.go:210: Creating room !0-hvmfClz7gk1ghOQTi5:host.docker.internal:45563 with version 10
      federation_room_join_partial_state_test.go:4499: Registered state_ids handler for event $mN1voF4gDGPdTSp3EUwOmCDlOFqlzgXW_KUR-AUXZ8U
      federation_room_join_partial_state_test.go:4540: Registered /state handler for event $mN1voF4gDGPdTSp3EUwOmCDlOFqlzgXW_KUR-AUXZ8U
  2026/06/08 12:23:47 Received send-join of event $kEyw4F8XinpYV9_VahFs5JkXlLv0JIgGb0MkavOKyjE
      client.go:860: [CSAPI] POST hs1/_matrix/client/v3/join/!0-hvmfClz7gk1ghOQTi5:host.docker.internal:45563 => 200 OK (80.730602ms)
      federation_room_join_partial_state_test.go:4393: /join request completed
      federation_room_join_partial_state_test.go:4478: Incoming state_ids request for event [$mN1voF4gDGPdTSp3EUwOmCDlOFqlzgXW_KUR-AUXZ8U] in room !0-hvmfClz7gk1ghOQTi5:host.docker.internal:45563
  2026/06/08 12:23:48 Received send-join of event $awFsIhzHfpfMVd_m_ei2qJfZt-YtGhCjhTYrYJzq9U0
      federation_room_join_partial_state_test.go:2405: Server.MustJoinRoom joined room ID !0-hvmfClz7gk1ghOQTi5:host.docker.internal:45563
      client.go:860: [CSAPI] GET hs1/_matrix/client/v3/sync => 200 OK (49.114711ms)
      client.go:860: [CSAPI] GET hs1/_matrix/client/v3/sync => 200 OK (9.370922ms)
      federation_room_join_partial_state_test.go:2415: Alice successfully received event $awFsIhzHfpfMVd_m_ei2qJfZt-YtGhCjhTYrYJzq9U0 via /sync
      client.go:860: [CSAPI] PUT hs1/_matrix/client/v3/devices/GPHZZFZGEP => 200 OK (4.413737ms)
      federation_room_join_partial_state_test.go:2418: @user-35-t26alice:hs1 sent device list update.
      federation_room_join_partial_state_test.go:2156: Complement server received m.device_list_update: {"device_display_name":"A new device name 1","device_id":"GPHZZFZGEP","prev_id":[],"stream_id":54,"user_id":"@user-35-t26alice:hs1"}
      federation_room_join_partial_state_test.go:2156: Complement server received m.device_list_update: {"device_display_name":"A new device name 1","device_id":"GPHZZFZGEP","prev_id":[],"stream_id":54,"user_id":"@user-35-t26alice:hs1"}
      federation_room_join_partial_state_test.go:2421: @charlie, @derek and @elsie received device list update.
      client.go:860: [CSAPI] GET hs1/_matrix/client/v3/sync => 200 OK (6.652265ms)
      client.go:860: [CSAPI] GET hs1/_matrix/client/v3/sync => 200 OK (48.310036ms)
      federation_room_join_partial_state_test.go:2439: Alice successfully received event $SxL9gU1hmed77R46CGYKccn6aDKuZBFGFcKN2eBkKRc via /sync
      client.go:860: [CSAPI] PUT hs1/_matrix/client/v3/devices/GPHZZFZGEP => 200 OK (5.036587ms)
      federation_room_join_partial_state_test.go:2497: @user-35-t26alice:hs1 sent device list update.
      federation_room_join_partial_state_test.go:2156: Complement server received m.device_list_update: {"device_display_name":"A new device name 2","device_id":"GPHZZFZGEP","prev_id":[54],"stream_id":56,"user_id":"@user-35-t26alice:hs1"}
      federation_room_join_partial_state_test.go:2500: @charlie and @derek received device list update.
      federation_room_join_partial_state_test.go:4485: Replying to /state_ids request for event [$mN1voF4gDGPdTSp3EUwOmCDlOFqlzgXW_KUR-AUXZ8U]
      client.go:860: [CSAPI] GET hs1/_matrix/client/v3/rooms/!0-hvmfClz7gk1ghOQTi5:host.docker.internal:45563/members => 200 OK (69.344577ms)
      federation_room_join_partial_state_test.go:2513: @user-35-t26alice:hs1's partial state join to !0-hvmfClz7gk1ghOQTi5:host.docker.internal:45563 completed.
      federation_room_join_partial_state_test.go:2156: Complement server received m.device_list_update: {"device_display_name":"A new device name 2","device_id":"GPHZZFZGEP","prev_id":[54],"stream_id":57,"user_id":"@user-35-t26alice:hs1"}
      federation_room_join_partial_state_test.go:2517: @elsie received missed device list update.
      client.go:860: [CSAPI] PUT hs1/_matrix/client/v3/devices/GPHZZFZGEP => 200 OK (5.751307ms)
      federation_room_join_partial_state_test.go:2520: @user-35-t26alice:hs1 sent device list update.
      federation_room_join_partial_state_test.go:2156: Complement server received m.device_list_update: {"device_display_name":"A new device name 3","device_id":"GPHZZFZGEP","prev_id":[56],"stream_id":59,"user_id":"@user-35-t26alice:hs1"}
      federation_room_join_partial_state_test.go:2156: Complement server received m.device_list_update: {"device_display_name":"A new device name 3","device_id":"GPHZZFZGEP","prev_id":[57],"stream_id":59,"user_id":"@user-35-t26alice:hs1"}
      federation_room_join_partial_state_test.go:2523: @charlie, @derek and @elsie received device list update.
      federation_room_join_partial_state_test.go:4416: Cleaning up after test...
      client.go:860: [CSAPI] GET hs1/_matrix/client/v3/rooms/!0-hvmfClz7gk1ghOQTi5:host.docker.internal:45563/members => 200 OK (3.937128ms)
      federation_room_join_partial_state_test.go:4418: @user-35-t26alice:hs1's partial state join to !0-hvmfClz7gk1ghOQTi5:host.docker.internal:45563 completed.
      client.go:860: [CSAPI] POST hs1/_matrix/client/v3/rooms/!0-hvmfClz7gk1ghOQTi5:host.docker.internal:45563/leave => 200 OK (25.974991ms)
      federation_room_join_partial_state_test.go:156: host.docker.internal:45563 saw @user-35-t26alice:hs1 leave test room !0-hvmfClz7gk1ghOQTi5:host.docker.internal:45563.
      federation_room_join_partial_state_test.go:152: host.docker.internal:33565 has already seen @user-35-t26alice:hs1 leave test room !0-hvmfClz7gk1ghOQTi5:host.docker.internal:45563.
      federation_room_join_partial_state_test.go:77: Received unexpected PDU: {"auth_events":["$613ehdee8-7NYiEo9wLU_E89o8bDbIvOvNdMfB8c_aw","$kEyw4F8XinpYV9_VahFs5JkXlLv0JIgGb0MkavOKyjE","$jnQmT8p0NUxGgUChevtHR2Qu027MlckWRQLfTsnaRXs"],"content":{"membership":"leave"},"depth":11,"hashes":{"sha256":"oaWwqeddMuOgqMSQs/IDHmDxHcxwNdjTTVJrhcHk0JM"},"origin_server_ts":1780921429379,"prev_events":["$SxL9gU1hmed77R46CGYKccn6aDKuZBFGFcKN2eBkKRc"],"room_id":"!0-hvmfClz7gk1ghOQTi5:host.docker.internal:45563","sender":"@user-35-t26alice:hs1","signatures":{"hs1":{"ed25519:a_zXSM":"y5zkhzV66kRDjAI1A/BUHRHnz7Ww1dki3S3o3nza3tBOnNOBA2E/C7Lf7WdOfiPTR9VLstNNufapfqaDYWhECA"}},"state_key":"@user-35-t26alice:hs1","type":"m.room.member"}

// WithWaitForLeave runs the given action and waits for the user to leave the room.
// WithWaitForLeave runs the given action and, when the resulting leave is
// expected to reach this server, waits for it. `leaveAction` is always run; the
// wait is skipped when `user` had already left the room (per their own
// homeserver, so the action produces no new leave) or when this server isn't in
// the room (so the leave won't be federated to us).
func (s *server) WithWaitForLeave(
Comment thread
MadLittleMods marked this conversation as resolved.
t *testing.T, room *federation.ServerRoom, userID string, leaveAction func(),
t *testing.T, room *federation.ServerRoom, user *client.CSAPI, leaveAction func(),
) {
userID := user.UserID
leaveChannel := make(chan gomatrixserverlib.PDU, 10)
removePDUHandler := s.AddPDUHandler(
func(e gomatrixserverlib.PDU) bool {
Expand All @@ -141,24 +146,70 @@ func (s *server) WithWaitForLeave(
)
defer removePDUHandler()

// We need to check if the user (on their homeserver) thinks they're in the
// room, before performing the `leaveAction` (to avoid races). If they are
// in the room we need to wait until the server sees the leave, but if they
// aren't no leave event will be sent out.
Comment thread
erikjohnston marked this conversation as resolved.
Outdated
userInRoom := userIsJoinedTo(t, user, room.RoomID)

@MadLittleMods MadLittleMods Jun 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Complement, the way we typically do this is using user.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(user.UserID, room.RoomID))

And since we seem to only use this for the negative case and return, I'm guessing that using the typical flow and failing the test is also fine (related to the points below about programming errors)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Complement, the way we typically do this is using user.MustSyncUntil(t, client.SyncReq{Since: aliceSince}, client.SyncJoinedTo(user.UserID, room.RoomID))

That only tests whether a new join has happened, not whether the user is joined at all

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That only tests whether a new join has happened, not whether the user is joined at all

I accidentally copied an incremental sync here but it will work just fine to tell you whether you're joined to a room if you use an initial sync.

But overall, I think this review point was more in the context when I didn't understand what we're trying to accomplish with WithWaitForLeave. It's a general clean-up function (that could be run at any stage of the test in the case of failures), not something used to actually wait for someone to leave as part of the test. Probably mostly a case of this being a bad name and awkward usage.


leaveAction()

memberEvent := room.CurrentState("m.room.member", userID)
membership := ""
if memberEvent != nil {
membership, _ = memberEvent.Membership()
if !userInRoom {
// The user had already left, so the action produced no new leave and
// none is coming: don't wait.
t.Logf("%s is not joined to test room %s; not waiting for them to leave.", userID, room.RoomID)
return
}
Comment thread
MadLittleMods marked this conversation as resolved.
if membership == "leave" {
t.Logf("%s has already seen %s leave test room %s.", s.ServerName(), userID, room.RoomID)
} else {
select {
case <-leaveChannel:
t.Logf("%s saw %s leave test room %s.", s.ServerName(), userID, room.RoomID)
break
case <-time.After(1 * time.Second):
t.Errorf("%s timed out waiting for %s to leave test room %s.", s.ServerName(), userID, room.RoomID)

if !s.isInRoom(room) {
// The homeserver only federates the leave to servers that are in the
// room. If we aren't, no leave PDU is coming to us, so don't block until
// the timeout.
t.Logf("%s is not in test room %s; not waiting for %s to leave.", s.ServerName(), room.RoomID, userID)
return
}
Comment thread
MadLittleMods marked this conversation as resolved.
Comment thread
erikjohnston marked this conversation as resolved.

@MadLittleMods MadLittleMods Jun 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many of the faster joins test flakes are due to the homeserver under test failing to contact Complement homeservers after they have been torn down. When this happens, subsequent tests can fail if they use a Complement homeserver that happens to have the same hostname:port as one which the homeserver under test has previously marked as offline.

-- #626

Is there anything about a federation request where our engineered homeserver can reject requests meant for some old homeserver. I'd assume keys used in the federation requests would be unique to each engineered Complement homeserver that we create and should fail authentication. Perhaps this is missing? For example, why isn't this already rejecting the stray requests:

// Check federation signature
fedReq, errResp := fclient.VerifyHTTPRequest(
req, time.Now(), srv.serverName, nil, srv.keyRing,
)
if fedReq == nil {
log.Printf(
"complement: Transaction '%s': HTTP Code %d. Invalid http request: %s",
transactionID, errResp.Code, errResp.JSON,
)
w.WriteHeader(errResp.Code)
b, _ := json.Marshal(errResp.JSON)
w.Write(b)
return
}

If this kind of thing is possible, it would eliminate the need for all of this cleanup logic while still being able to share the real homeserver deployment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Possibly, I haven't really looked whether there are architectural changes we could make to obviate the need for this cleanup code.

I think landing this as is makes sense to reduce the number of flakey tests we see though

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

urghh, it can make sense to merge as this pattern is already here and we can follow-up with a better strategy ⏩ . I find this existing approach so bad that I'm trying to avoid us taking this any further and trying to reason about it. Crap on top of crap.

@MadLittleMods MadLittleMods Jun 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've got a working solution to this in #880

As explained in the PR, I couldn't quite do what I was thinking here but there is still a good general solution to this problem by avoiding port re-use which lead to the same server names being used.

We can still merge this PR if you'd like but #880 would also solve the problem. I plan to strip all of this logic out. Just waiting on this to merge or not.


// Otherwise the action triggered the leave, which arrives as a PDU our
// handler matches. Wait on its channel rather than polling
// `room.CurrentState`: the room's current state is updated (by
// `room.AddEvent`) *before* the PDU callback runs, so returning on a
// `CurrentState` check could deregister our handler in the window before the
// callback fires, making the (expected) leave look unexpected to
// `HandleTransactionRequests`. This returns as soon as the leave arrives; the
// timeout is only a ceiling for declaring failure.
select {
case <-leaveChannel:
t.Logf("%s saw %s leave test room %s.", s.ServerName(), userID, room.RoomID)
case <-time.After(1 * time.Second):
t.Errorf("%s timed out waiting for %s to leave test room %s.", s.ServerName(), userID, room.RoomID)
}
}

Comment thread
erikjohnston marked this conversation as resolved.
// isInRoom reports whether this Complement server has a joined user in the room,
// according to its own `ServerRoom` view. The server reliably tracks its own
// users' membership (it created their join/leave events), so this answers "will
// the homeserver federate events in this room to us?".
func (s *server) isInRoom(room *federation.ServerRoom) bool {
for _, serverInRoom := range room.ServersInRoom() {
if serverInRoom == s.ServerName() {
return true
}
}
return false
}

// userIsJoinedTo reports whether the user is currently joined to the room,
// according to the user's own homeserver.
func userIsJoinedTo(t *testing.T, user *client.CSAPI, roomID string) bool {
t.Helper()
res := user.MustDo(t, "GET", []string{"_matrix", "client", "v3", "joined_rooms"})
joinedRooms := gjson.ParseBytes(client.ParseJSON(t, res)).Get("joined_rooms")
for _, joinedRoom := range joinedRooms.Array() {
if joinedRoom.Str == roomID {
return true
}
}
return false
}

// Wait for the server to receive the event with given event ID.
Expand Down Expand Up @@ -2249,7 +2300,7 @@ func TestPartialStateJoin(t *testing.T) {

// @t23alice:hs1 joins the room.
psjResult := beginPartialStateJoin(t, server1, room, alice)
defer server2.WithWaitForLeave(t, server2Room, alice.UserID, func() { psjResult.Destroy(t) })
defer server2.WithWaitForLeave(t, server2Room, alice, func() { psjResult.Destroy(t) })

// Both homeservers should receive device list updates.
renameDevice(t, alice, "A new device name 1")
Expand Down Expand Up @@ -2296,7 +2347,7 @@ func TestPartialStateJoin(t *testing.T) {
)
// NB: We register the `psjResult.Destroy()` cleanup twice. This is alright because it
// is idempotent. Here we wait for server 2 to observe the leave too.
defer server2.WithWaitForLeave(t, server2Room, alice.UserID, func() { psjResult.Destroy(t) })
defer server2.WithWaitForLeave(t, server2Room, alice, func() { psjResult.Destroy(t) })
joinEvent := room.CurrentState("m.room.member", server2.UserID("elsie"))
server1.MustSendTransaction(t, deployment, deployment.GetFullyQualifiedHomeserverName(t, "hs1"), []json.RawMessage{joinEvent.JSON()}, nil)
awaitEventViaSync(t, alice, room.RoomID, joinEvent.EventID(), "")
Expand Down Expand Up @@ -2473,7 +2524,7 @@ func TestPartialStateJoin(t *testing.T) {
syncToken = awaitEventViaSync(t, alice, partialStateRoom.RoomID, leaveEvent.EventID(), syncToken)

leaveSharedRoom = func() {
server2.WithWaitForLeave(t, server2Room, alice.UserID, func() {
server2.WithWaitForLeave(t, server2Room, alice, func() {
alice.MustLeaveRoom(t, roomID)
})
}
Expand Down Expand Up @@ -2533,7 +2584,7 @@ func TestPartialStateJoin(t *testing.T) {
// @t26alice:hs1 joins the room, followed by @elsie:server2.
// @elsie:server2 is kicked with an invalid event.
syncToken, server2Room, psjResult := setupIncorrectlyAcceptedKick(t, deployment, alice, server1, server2, deviceListUpdateChannel1, deviceListUpdateChannel2, room)
defer server2.WithWaitForLeave(t, server2Room, alice.UserID, func() { psjResult.Destroy(t) })
defer server2.WithWaitForLeave(t, server2Room, alice, func() { psjResult.Destroy(t) })

// @t26alice:hs1 sends out a device list update which is missed by @elsie:server2.
// @elsie:server2 must receive missed device list updates once the partial state join finishes.
Expand Down Expand Up @@ -2593,7 +2644,7 @@ func TestPartialStateJoin(t *testing.T) {
federation.WithPartialState(),
)
psjResult := beginPartialStateJoin(t, server1, room, alice)
defer server2.WithWaitForLeave(t, server2Room, alice.UserID, func() { psjResult.Destroy(t) })
defer server2.WithWaitForLeave(t, server2Room, alice, func() { psjResult.Destroy(t) })

// @t28alice:hs1 sends out a device list update which is missed by @elsie:server2.
// @elsie:server2 must receive missed device list updates once the partial state join finishes.
Expand Down Expand Up @@ -2996,7 +3047,7 @@ func TestPartialStateJoin(t *testing.T) {
},
client.SyncJoinedTo(server.UserID("charlie"), otherRoomID),
)
defer server.WithWaitForLeave(t, otherRoom, alice.UserID, func() { alice.MustLeaveRoom(t, otherRoomID) })
defer server.WithWaitForLeave(t, otherRoom, alice, func() { alice.MustLeaveRoom(t, otherRoomID) })

// Depending on the homeserver implementation, @t31alice:hs1 must have been told that either:
// * charlie updated their device list, or
Expand Down Expand Up @@ -4422,7 +4473,7 @@ func (psj *partialStateJoinResult) Destroy(t *testing.T) {
psj.Server.WithWaitForLeave(
t,
psj.ServerRoom,
psj.User.UserID,
psj.User,
func() { psj.User.MustLeaveRoom(t, psj.ServerRoom.RoomID) },
)
}
Expand Down
Loading