From 275e293f5431fcac4118d1fcd03bb65f6dd7953e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:44:18 -0700 Subject: [PATCH] fix(matrix): decline dead/abandoned invites instead of retrying forever (#56222) An invite to a room with no remaining members surfaces as "no servers in the room have been provided" or "room not found" on join. The pending invite was never cleared, so every gateway startup re-attempted the join and re-emitted the warning indefinitely. Detect that specific failure mode by narrow error-message match and call leave_room to decline the invite; transient/network errors leave the invite untouched for the next sync. Adds 5 tests. Reimplements the matrix portion of #33953 onto the current plugin adapter (gateway/platforms/matrix.py was relocated to plugins/platforms/matrix/adapter.py since the PR was opened). The two gateway/status.py fixes from that PR (wrapper-subcommand rejection, psutil start-time fallback) already landed on main independently. Reported by @Bougey; original patch authored by @KiraKatana. --- plugins/platforms/matrix/adapter.py | 13 +++++ tests/gateway/test_matrix.py | 84 +++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 0b663f1b7..adb880b4d 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -2947,6 +2947,19 @@ class MatrixAdapter(BasePlatformAdapter): return True except Exception as exc: logger.warning("Matrix: error joining %s: %s", room_id, exc) + # Abandoned rooms (no current members) surface as "no servers + # in the room have been provided" or "room not found". The + # pending invite keeps retrying every startup unless we + # explicitly leave it. The match is narrow enough that + # transient failures still leave the invite untouched for the + # next try. + msg = str(exc).lower() + if ("no servers" in msg) or ("room not found" in msg): + try: + await self._client.leave_room(RoomID(room_id)) + logger.info("Matrix: declined dead invite to %s", room_id) + except Exception: + pass return False def _schedule_invite_join( diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index 43aea0d0e..7481e66b3 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -4527,3 +4527,87 @@ class TestCreateMatrixSession: assert session.connector is fake_connector finally: await session.close() + + +class TestMatrixDeadInviteHandling: + """Tests for _join_room_by_id auto-leaving dead/abandoned rooms. + + Regression: when a room had no current members, ``join_room`` raised + ``MUnknown: Can't join remote room because no servers that are in the + room have been provided``. The pending invite stayed in the bot's view + of the world, so every gateway restart re-attempted the join and + re-emitted the warning indefinitely. There was no path that ever + cleared the invite. + """ + + def setup_method(self): + self.adapter = _make_adapter() + self.adapter._refresh_dm_cache = AsyncMock() + + @pytest.mark.asyncio + async def test_no_servers_error_triggers_leave(self): + join_err = Exception( + "Can't join remote room because no servers that are in the " + "room have been provided." + ) + self.adapter._client = types.SimpleNamespace( + join_room=AsyncMock(side_effect=join_err), + leave_room=AsyncMock(), + ) + + result = await self.adapter._join_room_by_id("!dead:example.org") + + assert result is False + self.adapter._client.leave_room.assert_awaited_once() + # leave_room receives a RoomID-wrapped value; verify the underlying str. + leave_arg = self.adapter._client.leave_room.await_args.args[0] + assert str(leave_arg) == "!dead:example.org" + + @pytest.mark.asyncio + async def test_room_not_found_error_triggers_leave(self): + join_err = Exception("M_NOT_FOUND: Room not found") + self.adapter._client = types.SimpleNamespace( + join_room=AsyncMock(side_effect=join_err), + leave_room=AsyncMock(), + ) + + await self.adapter._join_room_by_id("!gone:example.org") + self.adapter._client.leave_room.assert_awaited_once() + + @pytest.mark.asyncio + async def test_transient_error_does_not_trigger_leave(self): + """A network blip or 5xx must NOT decline the invite — the bot + should retry on the next sync cycle.""" + join_err = Exception("Connection reset by peer") + self.adapter._client = types.SimpleNamespace( + join_room=AsyncMock(side_effect=join_err), + leave_room=AsyncMock(), + ) + + result = await self.adapter._join_room_by_id("!transient:example.org") + assert result is False + self.adapter._client.leave_room.assert_not_awaited() + + @pytest.mark.asyncio + async def test_successful_join_does_not_attempt_leave(self): + self.adapter._client = types.SimpleNamespace( + join_room=AsyncMock(return_value=None), + leave_room=AsyncMock(), + ) + + result = await self.adapter._join_room_by_id("!alive:example.org") + assert result is True + assert "!alive:example.org" in self.adapter._joined_rooms + self.adapter._client.leave_room.assert_not_awaited() + + @pytest.mark.asyncio + async def test_leave_room_failure_is_swallowed(self): + """If leave_room itself fails (e.g. server returns 500), the helper + must still return False cleanly rather than re-raise.""" + self.adapter._client = types.SimpleNamespace( + join_room=AsyncMock(side_effect=Exception("no servers in the room")), + leave_room=AsyncMock(side_effect=Exception("500 internal")), + ) + + result = await self.adapter._join_room_by_id("!brokenleave:example.org") + assert result is False