mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
167 lines
6.5 KiB
Python
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"]
|