From 00f9b4f0ce3122020de4f2db459187705243eb50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Thu, 26 Mar 2026 02:10:43 +0900 Subject: [PATCH] fix: observe lsp reader task failures --- astrbot/_internal/protocols/lsp/client.py | 19 +++++++++ .../fixtures/hanging_lsp_server.py | 13 ++++++ tests/integration/test_lsp_integration.py | 21 ++++++++++ tests/unit/test_internal/test_lsp_client.py | 41 +++++++++++++++++++ 4 files changed, 94 insertions(+) create mode 100644 tests/integration/fixtures/hanging_lsp_server.py create mode 100644 tests/unit/test_internal/test_lsp_client.py diff --git a/astrbot/_internal/protocols/lsp/client.py b/astrbot/_internal/protocols/lsp/client.py index 18d0dc265..b529233a2 100644 --- a/astrbot/_internal/protocols/lsp/client.py +++ b/astrbot/_internal/protocols/lsp/client.py @@ -43,6 +43,24 @@ class AstrbotLspClient(BaseAstrbotLspClient): """True if connected to an LSP server.""" return self._connected + def _handle_reader_task_done(self, task: asyncio.Task[None]) -> None: + try: + exception = task.exception() + except asyncio.CancelledError: + return + + if exception is None: + if self._connected: + self._connected = False + log.warning("LSP reader task exited unexpectedly") + return + + self._connected = False + log.error( + "LSP reader task failed", + exc_info=(type(exception), exception, exception.__traceback__), + ) + async def connect(self) -> None: """ Connect to configured LSP servers. @@ -79,6 +97,7 @@ class AstrbotLspClient(BaseAstrbotLspClient): # Start reading responses in the background. self._reader_task = asyncio.create_task(self._read_responses()) + self._reader_task.add_done_callback(self._handle_reader_task_done) # Send initialize request await self.send_request( diff --git a/tests/integration/fixtures/hanging_lsp_server.py b/tests/integration/fixtures/hanging_lsp_server.py new file mode 100644 index 000000000..46a862dca --- /dev/null +++ b/tests/integration/fixtures/hanging_lsp_server.py @@ -0,0 +1,13 @@ +"""LSP fixture that never answers initialization.""" + +from __future__ import annotations + +import time + + +def main() -> None: + time.sleep(60) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/test_lsp_integration.py b/tests/integration/test_lsp_integration.py index 8716227da..6010a1a55 100644 --- a/tests/integration/test_lsp_integration.py +++ b/tests/integration/test_lsp_integration.py @@ -11,11 +11,13 @@ from pathlib import Path import anyio import pytest +from anyio.lowlevel import checkpoint from astrbot._internal.protocols.lsp.client import AstrbotLspClient TEST_DIR = Path(__file__).resolve().parent SERVER_PATH = TEST_DIR / "fixtures" / "echo_lsp_server.py" +HANGING_SERVER_PATH = TEST_DIR / "fixtures" / "hanging_lsp_server.py" @pytest.mark.anyio @@ -115,3 +117,22 @@ async def test_lsp_client_connect_does_not_corrupt_anyio_cancel_scope(): finally: with anyio.fail_after(5): await client.shutdown() + + +@pytest.mark.anyio +async def test_lsp_client_connect_timeout_does_not_corrupt_anyio_cancel_scope(): + """Test timeout-driven cancellation leaves later fail_after scopes usable.""" + client = AstrbotLspClient() + + with pytest.raises(TimeoutError): + with anyio.fail_after(0.1): + await client.connect_to_server( + command=[sys.executable, str(HANGING_SERVER_PATH)], + workspace_uri="file:///tmp", + ) + + with anyio.fail_after(5): + await client.shutdown() + + with anyio.fail_after(1): + await checkpoint() diff --git a/tests/unit/test_internal/test_lsp_client.py b/tests/unit/test_internal/test_lsp_client.py new file mode 100644 index 000000000..4524a08a9 --- /dev/null +++ b/tests/unit/test_internal/test_lsp_client.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from astrbot._internal.protocols.lsp.client import AstrbotLspClient + + +@pytest.mark.asyncio +async def test_lsp_reader_task_failure_marks_client_disconnected_and_logs(): + """Test reader task failures update connection state and are logged.""" + client = AstrbotLspClient() + fake_process = SimpleNamespace(stdout=MagicMock(), stdin=MagicMock()) + + with ( + patch( + "astrbot._internal.protocols.lsp.client.anyio.open_process", + AsyncMock(return_value=fake_process), + ), + patch.object( + client, + "_read_responses", + AsyncMock(side_effect=RuntimeError("reader crashed")), + ), + patch.object(client, "send_request", AsyncMock(return_value={})), + patch.object(client, "send_notification", AsyncMock()), + patch("astrbot._internal.protocols.lsp.client.log") as mock_log, + ): + await client.connect_to_server(["python", "fake_lsp.py"], "file:///tmp") + await asyncio.sleep(0) + + reader_task = client._reader_task + assert reader_task is not None + _ = reader_task.exception() + await asyncio.sleep(0) + + assert client.connected is False + mock_log.error.assert_called_once()