mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-17 01:49:15 +08:00
fix: observe lsp reader task failures
This commit is contained in:
@@ -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(
|
||||
|
||||
13
tests/integration/fixtures/hanging_lsp_server.py
Normal file
13
tests/integration/fixtures/hanging_lsp_server.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
41
tests/unit/test_internal/test_lsp_client.py
Normal file
41
tests/unit/test_internal/test_lsp_client.py
Normal 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()
|
||||
Reference in New Issue
Block a user