From 62882b8e6f717a7b5305a543d7b603c8d41ea82c Mon Sep 17 00:00:00 2001 From: Que0x Date: Fri, 3 Jul 2026 12:39:01 +0300 Subject: [PATCH] fix(matrix): isolate per-event failures in _dispatch_sync gather MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_dispatch_sync` gathers the mautrix per-event handler tasks with a bare `asyncio.gather(*tasks)`. Without `return_exceptions=True`, the first handler that raises aborts the gather, so the sibling events in the same sync response are dropped unprocessed — the exception propagates up to the sync loop, which logs a single "sync error" and moves on. The invite/redaction gathers a few lines above already use `return_exceptions=True`. Use `return_exceptions=True` and log each failing handler, so one bad event no longer takes out the rest of its batch and per-event failures stay visible. Regression test: a batch with one failing and one succeeding handler no longer raises, the good handler still runs, and the failure is logged (mutation- verified — reverting re-raises RuntimeError out of _dispatch_sync). --- plugins/platforms/matrix/adapter.py | 13 +++++++++++- tests/gateway/test_matrix.py | 33 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) 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