diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index ebe9ebbbf..dac4dbd16 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -2351,7 +2351,18 @@ class MatrixAdapter(BasePlatformAdapter): if inspect.isawaitable(tasks): tasks = await tasks if tasks: - await asyncio.gather(*tasks) + # return_exceptions=True so one failing event handler doesn't abort + # the whole gather and silently drop the SIBLING events in the same + # sync response (a bare gather re-raises the first exception, leaving + # the rest of the batch unprocessed). Mirrors the invite/redaction + # gathers above. Surface each failure instead of swallowing it. + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, Exception): + logger.warning( + "Matrix: event handler failed during sync dispatch: %s", + result, + ) def _is_self_sender(self, sender: str) -> bool: """Return True if the sender refers to the bot's own account. diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index 748422045..d239728b7 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -5276,3 +5276,36 @@ class TestDeviceIdRecoveryOnReconnect: assert None not in _verify_call.args[0]["@bot:example.org"] await adapter.disconnect() + + +class TestMatrixDispatchSyncIsolation: + """A failing mautrix event handler must not abort the whole sync batch. + + ``_dispatch_sync`` gathers the per-event handler tasks. Without + ``return_exceptions=True`` the first exception aborts the gather and the + sibling events in the same sync response are silently dropped. + """ + + @pytest.mark.asyncio + async def test_dispatch_sync_isolates_failing_handler(self, caplog): + import logging + + adapter = _make_adapter() + ran = {"ok": False} + + async def _boom(): + raise RuntimeError("handler boom") + + async def _ok(): + ran["ok"] = True + + client = MagicMock() + client.handle_sync = MagicMock(return_value=[_boom(), _ok()]) + adapter._client = client + + with caplog.at_level(logging.WARNING): + # Must not raise despite the failing handler. + await adapter._dispatch_sync({"next_batch": "s1"}) + + assert ran["ok"] is True # the sibling handler still ran + assert "event handler failed" in caplog.text # failure surfaced, not swallowed