fix: observe lsp reader task failures

This commit is contained in:
邹永赫
2026-03-26 02:10:43 +09:00
parent 5fa18dd836
commit 00f9b4f0ce
4 changed files with 94 additions and 0 deletions

View File

@@ -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(

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()