OpenSpace/openspace/services/lsp/server_instance.py
2026-07-17 11:43:42 +08:00

167 lines
6.5 KiB
Python

from __future__ import annotations
import asyncio
import os
from pathlib import Path
from typing import Any, Callable
from openspace.services.lsp.client import LSPClient
from openspace.services.lsp.types import LSPServerConfig, LSPServerState
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
LSP_ERROR_CONTENT_MODIFIED = -32801
MAX_RETRIES_FOR_TRANSIENT_ERRORS = 3
RETRY_BASE_DELAY_MS = 500
def _file_uri(path: str) -> str:
return Path(path).resolve().as_uri()
class LSPServerInstance:
"""Lifecycle wrapper for one configured language server."""
def __init__(self, name: str, config: LSPServerConfig) -> None:
self.name = name
self.config = config
self.state: LSPServerState = "stopped"
self.start_time: float | None = None
self.last_error: Exception | None = None
self.restart_count = 0
self._crash_recovery_count = 0
self._client = LSPClient(name, self._on_crash)
def _on_crash(self, error: Exception) -> None:
self.state = "error"
self.last_error = error
self._crash_recovery_count += 1
async def start(self) -> None:
if self.state in {"running", "starting"}:
return
if self.state == "error" and self._crash_recovery_count > self.config.max_restarts:
error = RuntimeError(
f"LSP server '{self.name}' exceeded max crash recovery attempts ({self.config.max_restarts})"
)
self.last_error = error
raise error
self.state = "starting"
try:
await self._client.start(
self.config.command,
self.config.args,
env=self.config.env,
cwd=self.config.workspace_folder,
)
workspace = self.config.workspace_folder or os.getcwd()
workspace_uri = Path(workspace).resolve().as_uri()
init_params = {
"processId": os.getpid(),
"initializationOptions": self.config.initialization_options or {},
"workspaceFolders": [{"uri": workspace_uri, "name": Path(workspace).name}],
"rootPath": workspace,
"rootUri": workspace_uri,
"capabilities": {
"workspace": {"configuration": False, "workspaceFolders": False},
"textDocument": {
"synchronization": {
"dynamicRegistration": False,
"willSave": False,
"willSaveWaitUntil": False,
"didSave": True,
},
"publishDiagnostics": {
"relatedInformation": True,
"tagSupport": {"valueSet": [1, 2]},
"versionSupport": False,
"codeDescriptionSupport": True,
"dataSupport": False,
},
"hover": {"dynamicRegistration": False, "contentFormat": ["markdown", "plaintext"]},
"definition": {"dynamicRegistration": False, "linkSupport": True},
"references": {"dynamicRegistration": False},
"documentSymbol": {
"dynamicRegistration": False,
"hierarchicalDocumentSymbolSupport": True,
},
"callHierarchy": {"dynamicRegistration": False},
},
"general": {"positionEncodings": ["utf-16"]},
},
}
await asyncio.wait_for(
self._client.initialize(init_params),
timeout=self.config.startup_timeout_ms / 1000,
)
self.state = "running"
self.start_time = asyncio.get_running_loop().time()
self._crash_recovery_count = 0
except Exception as exc:
await self._client.stop()
self.state = "error"
self.last_error = exc
raise
async def stop(self) -> None:
if self.state in {"stopped", "stopping"}:
return
self.state = "stopping"
try:
await self._client.stop()
self.state = "stopped"
except Exception as exc:
self.state = "error"
self.last_error = exc
raise
async def restart(self) -> None:
await self.stop()
self.restart_count += 1
if self.restart_count > self.config.max_restarts:
raise RuntimeError(
f"Max restart attempts ({self.config.max_restarts}) exceeded for server '{self.name}'"
)
await self.start()
def is_healthy(self) -> bool:
return self.state == "running" and self._client.is_initialized
async def send_request(self, method: str, params: Any) -> Any:
if not self.is_healthy():
detail = f", last error: {self.last_error}" if self.last_error else ""
raise RuntimeError(f"Cannot send request to LSP server '{self.name}': server is {self.state}{detail}")
last_error: Exception | None = None
for attempt in range(MAX_RETRIES_FOR_TRANSIENT_ERRORS + 1):
try:
return await asyncio.wait_for(
self._client.send_request(method, params),
timeout=self.config.request_timeout_ms / 1000,
)
except Exception as exc:
last_error = exc
code = getattr(exc, "code", None)
if code == LSP_ERROR_CONTENT_MODIFIED and attempt < MAX_RETRIES_FOR_TRANSIENT_ERRORS:
await asyncio.sleep((RETRY_BASE_DELAY_MS * (2**attempt)) / 1000)
continue
break
raise RuntimeError(
f"LSP request '{method}' failed for server '{self.name}': {last_error or 'unknown error'}"
)
async def send_notification(self, method: str, params: Any) -> None:
if not self.is_healthy():
raise RuntimeError(f"Cannot send notification to LSP server '{self.name}': server is {self.state}")
await self._client.send_notification(method, params)
def on_notification(self, method: str, handler: Callable[[Any], Any]) -> None:
self._client.on_notification(method, handler)
def on_request(self, method: str, handler: Callable[[Any], Any]) -> None:
self._client.on_request(method, handler)
__all__ = ["LSPServerInstance", "_file_uri"]