"""Service-level orchestration for LSP clients. :class:`LSPService` bridges the synchronous file_operations layer and the async :class:`agent.lsp.client.LSPClient`: one asyncio loop in a background thread, one lazily spawned client per ``(server_id, workspace_root)`` — servers flagged ``multi_root`` (pyright) get ONE client per ``server_id`` and further roots (typically sibling git worktrees) are attached to the running process via ``workspace/didChangeWorkspaceFolders`` — a **broken-set** of pairs that failed to spawn/initialize (retried only after ``lsp.broken_retry_seconds``; never, by default), and a **delta baseline** per file (``snapshot_baseline()`` runs BEFORE a write; the next ``get_diagnostics_sync()`` returns only diagnostics not in it). Off unless config enables it. """ from __future__ import annotations import asyncio import fnmatch import logging import math import os import threading import time from typing import Any, Callable, Dict, List, Optional, Tuple from agent.lsp import eventlog from agent.lsp.client import DIAGNOSTICS_DOCUMENT_WAIT, LSPClient, _diagnostic_key as _diag_key from agent.lsp.servers import ( SERVERS, UNTRUSTED_SAFE_SERVERS, ServerContext, ServerDef, custom_servers, find_server_for_file, language_id_for, ) from agent.lsp.workspace import clear_cache, is_trusted_workspace, operator_workspace_roots, resolve_workspace_for_file logger = logging.getLogger("agent.lsp.manager") DEFAULT_IDLE_TIMEOUT = 600 # seconds; servers idle for >10min get reaped _DELTA_BASELINE_CAP = 256 # per-file pre-write snapshots; paths never written again would otherwise live forever (#62950) MIN_IDLE_TIMEOUT = 30 # floor for config values; must exceed any per-op wait budget _Key = Tuple[str, str] _Diags = List[Dict[str, Any]] def _float_or(value: Any, default: float) -> float: try: return float(value) if value is not None else default except (TypeError, ValueError): return default def _path_list(value: Any) -> Optional[List[str]]: """A config list of paths, ``~``-expanded; ``None`` when the value is not a list of strings.""" if value is None: return [] if not isinstance(value, list) or not all(isinstance(p, str) for p in value): return None return [os.path.expanduser(p) for p in value if p] def _parse_exclude_roots(value: Any) -> Optional[List[str]]: """``lsp.exclude_roots``; ``None`` (fail closed: every root excluded) for a malformed value.""" if (roots := _path_list(value)) is None: eventlog.event_log.warning( "lsp.exclude_roots must be a list of glob patterns, e.g. ['~/big-monorepo', '/srv/*/vendor'] " "(got %s); LSP is skipped for every workspace until the key is fixed", type(value).__name__) return roots def parse_trusted_workspaces(value: Any) -> List[str]: """``lsp.trusted_workspaces``; a malformed value trusts nothing extra.""" if (roots := _path_list(value)) is None: eventlog.event_log.warning( "lsp.trusted_workspaces must be a list of directories, e.g. ['~/code/my-app'] (got %s); only the " "workspaces you launched Hermes or opened a session in are trusted until the key is fixed", type(value).__name__) return roots or [] def _client_key(srv: ServerDef, root: str, trusted: bool) -> _Key: """Cache key for the client serving ``root``: multi-root servers share one process per ``server_id`` across trusted roots; everything else, untrusted roots included, is keyed per resolved project root (a shared process keeps the trust its first root spawned it with).""" return (srv.server_id, "" if srv.multi_root and trusted else root) class _BackgroundLoop: """A daemon thread owning one asyncio loop; :meth:`run` blocks on a coroutine.""" def __init__(self) -> None: self._loop: Optional[asyncio.AbstractEventLoop] = None self._thread: Optional[threading.Thread] = None self._ready = threading.Event() def start(self) -> None: if self._thread is not None: return self._thread = threading.Thread(target=self._run_forever, name="hermes-lsp-loop", daemon=True) self._thread.start() self._ready.wait(timeout=5.0) def _run_forever(self) -> None: loop = self._loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) self._ready.set() try: loop.run_forever() finally: try: loop.close() except Exception: # noqa: BLE001 pass def run(self, coro, *, timeout: Optional[float] = None) -> Any: """Submit a coroutine to the loop and block for its result (or raise).""" from agent.async_utils import safe_schedule_threadsafe if self._loop is None: if asyncio.iscoroutine(coro): coro.close() raise RuntimeError("background loop not started") if (fut := safe_schedule_threadsafe(coro, self._loop)) is None: raise RuntimeError("background loop not running") try: return fut.result(timeout=timeout) except Exception: fut.cancel() raise def stop(self) -> None: loop, self._loop = self._loop, None thread, self._thread = self._thread, None if loop is None: return try: loop.call_soon_threadsafe(loop.stop) except RuntimeError: pass if thread is not None: thread.join(timeout=2.0) class LSPService: """The process-wide LSP service; use :func:`agent.lsp.get_service` rather than constructing directly.""" def __init__( self, *, enabled: bool, wait_mode: str, wait_timeout: float, install_strategy: str, binary_overrides: Optional[Dict[str, List[str]]] = None, env_overrides: Optional[Dict[str, Dict[str, str]]] = None, init_overrides: Optional[Dict[str, Dict[str, Any]]] = None, disabled_servers: Optional[List[str]] = None, idle_timeout: float = DEFAULT_IDLE_TIMEOUT, extra_servers: Optional[List[ServerDef]] = None, broken_retry_seconds: float = 0.0, warmup_timeout: float = 0.0, exclude_roots: Any = None, trusted_workspaces: Any = None, ) -> None: self._enabled = enabled self._wait_mode = wait_mode if wait_mode in {"document", "full"} else "document" self._wait_timeout = wait_timeout self._install_strategy = install_strategy self._binary_overrides = binary_overrides or {} self._env_overrides = env_overrides or {} self._init_overrides = init_overrides or {} self._disabled_servers = set(disabled_servers or []) self._idle_timeout = idle_timeout self._extra_servers: List[ServerDef] = list(extra_servers or []) self._broken_retry = max(0.0, broken_retry_seconds) self._warmup_timeout = max(0.0, warmup_timeout) # ``None`` = misconfigured → fail closed (every root excluded) until the user fixes the key: a # bare string here means "exclude that workspace", and excluding nothing would re-pay the stall it # was meant to avoid. self._exclude_roots: Optional[List[str]] = _parse_exclude_roots(exclude_roots) self._trusted_workspaces: List[str] = parse_trusted_workspaces(trusted_workspaces) self._untrusted_skipped: set = set() # (server_id, root) pairs denied by workspace trust self._operator_roots = frozenset(operator_workspace_roots()) # see _note_operator_roots self._loop = _BackgroundLoop() if self._enabled: self._loop.start() # Per-(server_id, workspace_root) state self._clients: Dict[_Key, LSPClient] = {} # (server_id, root) → monotonic deadline after which the pair may be retried (inf = lifetime). self._broken: Dict[_Key, float] = {} self._spawning: Dict[_Key, asyncio.Future] = {} self._last_used: Dict[_Key, float] = {} self._state_lock = threading.Lock() self._idle_reaper_task: Optional[asyncio.Task] = None # abs file path → diagnostics snapshot taken immediately before a write. self._delta_baseline: Dict[str, _Diags] = {} if self._enabled and self._idle_timeout > 0: self._loop.run(self._start_idle_reaper(), timeout=2.0) @classmethod def create_from_config(cls) -> Optional["LSPService"]: """Build a service from ``hermes_cli.config``; ``None`` if config can't load.""" try: from hermes_cli.config import load_config_readonly cfg = load_config_readonly() except Exception as e: # noqa: BLE001 logger.debug("LSP config load failed: %s", e) return None lsp_cfg = cfg.get("lsp") if isinstance(cfg, dict) else None lsp_cfg = lsp_cfg if isinstance(lsp_cfg, dict) else {} try: idle_timeout = float(lsp_cfg.get("idle_timeout", DEFAULT_IDLE_TIMEOUT)) except (TypeError, ValueError): idle_timeout = DEFAULT_IDLE_TIMEOUT if 0 < idle_timeout < MIN_IDLE_TIMEOUT: # Below the per-op wait budget the reaper could kill a client mid-flight and the outer # timeout would then mark the pair broken for the process lifetime. Clamp (0 still disables). idle_timeout = MIN_IDLE_TIMEOUT servers_cfg = lsp_cfg.get("servers") or {} servers = {n: c for n, c in servers_cfg.items() if isinstance(c, dict)} if isinstance(servers_cfg, dict) else {} return cls( enabled=bool(lsp_cfg.get("enabled", True)), wait_mode=lsp_cfg.get("wait_mode", "document"), wait_timeout=float(lsp_cfg.get("wait_timeout", DIAGNOSTICS_DOCUMENT_WAIT)), install_strategy=lsp_cfg.get("install_strategy", "auto"), binary_overrides={n: c["command"] for n, c in servers.items() if isinstance(c.get("command"), list) and c["command"]}, env_overrides={n: {k: str(v) for k, v in c["env"].items()} for n, c in servers.items() if isinstance(c.get("env"), dict)}, init_overrides={n: c["initialization_options"] for n, c in servers.items() if isinstance(c.get("initialization_options"), dict)}, disabled_servers=[n for n, c in servers.items() if c.get("disabled")], idle_timeout=idle_timeout, extra_servers=custom_servers(servers), broken_retry_seconds=_float_or(lsp_cfg.get("broken_retry_seconds"), 0.0), warmup_timeout=_float_or(lsp_cfg.get("warmup_timeout"), 0.0), exclude_roots=lsp_cfg.get("exclude_roots"), trusted_workspaces=lsp_cfg.get("trusted_workspaces"), ) def _server_for(self, file_path: str) -> Optional[ServerDef]: """Config-declared servers first (they may claim an extension ahead of a built-in), then the registry.""" extra = find_server_for_file(file_path, self._extra_servers) if self._extra_servers else None return extra or find_server_for_file(file_path) def handles_extension(self, ext: str) -> bool: """True iff a config-declared or built-in server claims ``ext`` (pre-write capture decision).""" return any(ext.lower() in s.extensions for s in (*self._extra_servers, *SERVERS)) # ---- public API ---- def is_active(self) -> bool: """Return True iff this service should be consulted at all.""" return self._enabled def _broken_key(self, srv: ServerDef, file_path: str) -> Optional[_Key]: """``(server_id, per-server root)`` broken-set key, or ``None`` when the file isn't gated in. Falls back to the workspace root when the per-server resolver fails — the same key ``_get_or_spawn`` would have used when it failed. """ ws_root, gated = resolve_workspace_for_file(file_path) if not (ws_root and gated): return None try: return (srv.server_id, srv.resolve_root(file_path, ws_root) or ws_root) except Exception: # noqa: BLE001 return (srv.server_id, ws_root) def enabled_for(self, file_path: str) -> bool: """True iff LSP should run for this file: registered non-disabled server, git workspace, and pair not broken (a failed server costs nothing until ``hermes lsp restart`` / exit).""" srv = self._server_for(file_path) if self._enabled else None if srv is None or srv.server_id in self._disabled_servers: return False key = self._broken_key(srv, file_path) if key is None: return False if self._root_excluded(key[1]): eventlog.log_root_excluded(srv.server_id, key[1], file_path, invalid=self._exclude_roots is None) return False self._note_operator_roots() if self._untrusted_denied(srv, key[1], file_path, self._trusted(key[1])): return False if self._is_broken(key): eventlog.log_skipped_broken(srv.server_id, key[1], file_path, retry_in=self._broken_retry_in(key)) return False return True def _note_operator_roots(self) -> None: """Remember this thread's ``operator_workspace_roots``. Called where a tool thread enters (it sees the session's cwd; the loop thread that spawns servers does not), so trust is per process: a workspace any session was opened in stays trusted until shutdown.""" if not (roots := operator_workspace_roots()) <= self._operator_roots: with self._state_lock: # rebind, never mutate: other threads read the old set lock-free self._operator_roots = self._operator_roots | roots def _trusted(self, root: str) -> bool: return is_trusted_workspace(root, self._trusted_workspaces, self._operator_roots) def _live_key(self, srv: ServerDef, root: str) -> _Key: """The client key for ``root`` (under ``_state_lock``): a multi-root server started while ``root`` was untrusted keeps its own client after ``root`` becomes trusted, so it is found and released rather than orphaned.""" own = (srv.server_id, root) return own if own in self._clients or own in self._spawning else _client_key(srv, root, self._trusted(root)) def _untrusted_denied(self, srv: ServerDef, root: str, file_path: str, trusted: bool) -> bool: """True iff ``srv`` may run project code and ``root`` is not a trusted workspace (deny by default: only ``UNTRUSTED_SAFE_SERVERS`` start in a checkout the operator has not trusted).""" if trusted or srv.server_id in UNTRUSTED_SAFE_SERVERS: return False with self._state_lock: self._untrusted_skipped.add((srv.server_id, root)) eventlog.log_untrusted_skipped(srv.server_id, root, file_path) return True def _root_excluded(self, root: str) -> bool: """True iff ``root`` matches an ``lsp.exclude_roots`` glob (or the key is misconfigured).""" if self._exclude_roots is None: return True return any(fnmatch.fnmatchcase(root, pat) or fnmatch.fnmatchcase(root, pat.rstrip(os.sep) + os.sep + "*") for pat in self._exclude_roots) def _is_broken(self, key: _Key) -> bool: """Broken-set membership; an entry whose retry deadline passed is dropped so the pair gets one more try.""" deadline = self._broken.get(key) if deadline is None: return False if time.monotonic() >= deadline: self._broken.pop(key, None) return False return True def _broken_retry_in(self, key: _Key) -> Optional[float]: deadline = self._broken.get(key, math.inf) return None if math.isinf(deadline) else max(0.0, deadline - time.monotonic()) def _mark_broken(self, key: _Key) -> None: self._broken[key] = time.monotonic() + self._broken_retry if self._broken_retry > 0 else math.inf def _wait_budget(self, file_path: str) -> float: """``wait_timeout`` for a root with a running client; ``max(wait_timeout, warmup_timeout)`` for a cold root, whose first request also pays the spawn/initialize and the server's initial program build.""" if self._warmup_timeout <= self._wait_timeout: return self._wait_timeout srv = self._server_for(file_path) key = self._broken_key(srv, file_path) if srv is not None else None if key is None: return self._wait_timeout with self._state_lock: client = self._clients.get(self._live_key(srv, key[1])) return self._wait_timeout if client is not None and client.is_running else self._warmup_timeout def snapshot_baseline(self, file_path: str) -> None: """Snapshot current diagnostics for ``file_path`` as the delta baseline (call BEFORE a write). Best-effort: failures are swallowed so a flaky server can't break a write, but they mark the pair broken.""" if not self.enabled_for(file_path): return try: # Outer join budget must exceed the inner wait or a slow-but-alive server gets falsely # marked broken; it is a ceiling only — the inner wait returns as soon as it completes. budget = self._wait_budget(file_path) t = max(DIAGNOSTICS_DOCUMENT_WAIT + 3.0, budget + 3.0) diags = self._loop.run(self._snapshot_async(file_path, budget), timeout=t) except Exception as e: # noqa: BLE001 logger.debug("baseline snapshot failed for %s: %s", file_path, e) self._mark_broken_for_file(file_path, e) diags = [] self._set_delta_baseline(os.path.abspath(file_path), diags or []) def _set_delta_baseline(self, abs_path: str, diags: _Diags) -> None: """Store a baseline, refreshing recency (pop + reinsert) so eviction tracks write order. Callers run on arbitrary threads; the multi-step mutation needs the lock (callers don't hold it).""" with self._state_lock: self._delta_baseline.pop(abs_path, None) self._delta_baseline[abs_path] = diags while len(self._delta_baseline) > _DELTA_BASELINE_CAP: del self._delta_baseline[next(iter(self._delta_baseline))] def get_diagnostics_sync( self, file_path: str, *, delta: bool = True, timeout: Optional[float] = None, line_shift: Optional[Callable[[int], Optional[int]]] = None, ) -> _Diags: """Synchronously open ``file_path``, wait for diagnostics, return them. Never raises. With ``delta`` (default) the result excludes the :meth:`snapshot_baseline`; ``line_shift`` (from :func:`agent.lsp.range_shift.build_line_shift`) remaps that baseline into post-edit coordinates first, so pre-existing diagnostics that merely moved don't look introduced by this edit. ``[]`` when LSP is disabled, nothing matches, or the server can't be spawned. """ if not self.enabled_for(file_path): return [] server_id = self._server_for(file_path).server_id # enabled_for guarantees a match try: budget = self._wait_budget(file_path) t = timeout if timeout is not None else budget + 2.0 diags = self._loop.run(self._open_and_wait_async(file_path, budget=budget), timeout=t) except Exception as e: # noqa: BLE001 if isinstance(e, asyncio.TimeoutError): eventlog.log_timeout(server_id, file_path) logger.debug("LSP diagnostics timeout for %s: %s", file_path, e) else: eventlog.log_server_error(server_id, file_path, e) logger.debug("LSP diagnostics fetch failed for %s: %s", file_path, e) self._mark_broken_for_file(file_path, e) return [] if diags is None: # Server alive but no verdict on the post-edit content in budget (common for tsserver on big # projects). Report "no data" rather than stale stores — that would be the ghost-diagnostics # bug. Not marked broken: slow is not dead. eventlog.log_timeout(server_id, file_path, kind="fresh diagnostics") return [] if delta: diags = self._apply_delta(file_path, diags, line_shift) if diags: eventlog.log_diagnostics(server_id, file_path, len(diags)) else: eventlog.log_clean(server_id, file_path) return diags def _apply_delta(self, file_path: str, diags: _Diags, line_shift: Optional[Callable[[int], Optional[int]]]) -> _Diags: """Drop diagnostics present in the pre-write baseline, then roll the baseline forward.""" abs_path = os.path.abspath(file_path) baseline = self._delta_baseline.get(abs_path) or [] if baseline: if line_shift is not None: # Entries that map into a deleted region drop out — they no longer apply. from agent.lsp.range_shift import shift_baseline baseline = shift_baseline(baseline, line_shift) seen = {_diag_key(d) for d in baseline} diags = [d for d in diags if _diag_key(d) not in seen] # Roll the baseline forward so the next call is a delta against this state. try: fresh = self._loop.run(self._current_diags_async(file_path), timeout=2.0) or [] except Exception: # noqa: BLE001 fresh = [] if fresh: self._set_delta_baseline(abs_path, fresh) return diags def _mark_broken_for_file(self, file_path: str, exc: BaseException) -> None: """Mark the file's ``(server_id, root)`` pair broken after an outer timeout/error. The outer ``_loop.run`` timeout cancels the in-flight spawn before ``_get_or_spawn`` could record the failure; without this every later write would re-pay the full timeout. Also kills any half-initialized client and logs the failure once.""" srv = self._server_for(file_path) key = self._broken_key(srv, file_path) if srv is not None else None if key is None: return already_broken = self._is_broken(key) self._mark_broken(key) with self._state_lock: ckey = self._live_key(srv, key[1]) client = self._clients.pop(ckey, None) self._last_used.pop(ckey, None) if client is not None: try: # Fire-and-forget shutdown — we're already on a slow path. self._loop.run(client.shutdown(), timeout=1.0) except Exception: # noqa: BLE001 pass if not already_broken: eventlog.log_spawn_failed(key[0], key[1], exc) def shutdown(self) -> None: """Tear down all clients and stop the background loop.""" if not self._enabled: return try: self._loop.run(self._shutdown_async(), timeout=10.0) except Exception as e: # noqa: BLE001 logger.debug("LSP shutdown error: %s", e) self._loop.stop() clear_cache() def get_status(self) -> Dict[str, Any]: """Return a snapshot of the service for ``hermes lsp status``.""" with self._state_lock: clients = [ {"server_id": c.server_id, "workspace_root": c.workspace_root, "workspace_folders": list(c.workspace_folders), "state": c.state, "running": c.is_running} for c in self._clients.values() ] broken = [key for key, deadline in self._broken.items() if time.monotonic() < deadline] untrusted = sorted(pair for pair in self._untrusted_skipped if not self._trusted(pair[1])) return { "enabled": self._enabled, "wait_mode": self._wait_mode, "wait_timeout": self._wait_timeout, "install_strategy": self._install_strategy, "clients": clients, "broken": broken, "disabled_servers": sorted(self._disabled_servers), "broken_retry_seconds": self._broken_retry, "warmup_timeout": self._warmup_timeout, "exclude_roots": list(self._exclude_roots) if self._exclude_roots is not None else "INVALID", "trusted_workspaces": list(self._trusted_workspaces), "untrusted_skipped": untrusted, } # ---- async internals ---- async def _snapshot_async(self, file_path: str, budget: Optional[float] = None) -> _Diags: # No fresh data for the pre-edit content → empty baseline. Safe: the delta # filter then removes less, never more. Never seed from stale stores. return await self._open_and_wait_async(file_path, snapshot=True, budget=budget) or [] async def _open_and_wait_async( self, file_path: str, *, snapshot: bool = False, budget: Optional[float] = None, ) -> Optional[_Diags]: """Open + wait for FRESH diagnostics: ``[]`` = checked clean, ``None`` = no verdict in budget. Callers must not substitute stale data for either. ``snapshot`` mode (pre-write baseline) skips didSave; both modes wait at most ``budget`` (``lsp.wait_timeout`` unless the caller computed a cold-root warm-up budget via :meth:`_wait_budget`). """ client = await self._get_or_spawn(file_path) if client is None: return None try: srv = self._server_for(file_path) version = await client.open_file(file_path, language_id=language_id_for(file_path, srv)) if not snapshot: await client.save_file(file_path) fresh = await client.wait_for_diagnostics( file_path, version, mode=self._wait_mode, timeout=budget if budget is not None else self._wait_timeout, ) except Exception as e: # noqa: BLE001 if snapshot: logger.debug("snapshot open/wait failed: %s", e) else: logger.debug("open/wait failed for %s: %s", file_path, e) return None self._touch(client) return list(client.diagnostics_for(file_path, fresh_only=True)) if fresh else None async def _current_diags_async(self, file_path: str) -> _Diags: ws, gated = resolve_workspace_for_file(file_path) srv = self._server_for(file_path) if not (ws and gated and srv): return [] # Same key _get_or_spawn() stored under: single-root servers live under their # resolved project root (a nested package.json), not the enclosing workspace. root = srv.resolve_root(file_path, ws) if root is None: return [] with self._state_lock: client = self._clients.get(self._live_key(srv, root)) return list(client.diagnostics_for(file_path, fresh_only=True)) if client else [] async def _get_or_spawn(self, file_path: str) -> Optional[LSPClient]: srv = self._server_for(file_path) if srv is None: return None if srv.server_id in self._disabled_servers: eventlog.log_disabled(srv.server_id, file_path, "disabled in config") return None ws_root, gated = resolve_workspace_for_file(file_path) if not (ws_root and gated): eventlog.log_no_project_root(srv.server_id, file_path) return None root = srv.resolve_root(file_path, ws_root) if root is None: eventlog.log_disabled(srv.server_id, file_path, "exclude marker hit (server gated off)") return None if self._untrusted_denied(srv, root, file_path, self._trusted(root)): return None if self._is_broken((srv.server_id, root)): return None with self._state_lock: key = self._live_key(srv, root) # Derived with the key: trust only grows, so one read before the lock could store an # untrusted spawn under the shared multi-root key every trusted root then attaches to. trusted = key[1] == "" or self._trusted(root) client = self._clients.get(key) if client is not None and client.is_running: self._last_used[key] = time.time() else: client = None spawning = self._spawning.get(key) owner = spawning is None if owner: spawning = self._spawning[key] = asyncio.get_running_loop().create_future() if client is not None: eventlog.log_active(srv.server_id, root) return await self._attach_root(srv, client, root) if not owner: try: client = await spawning except Exception: # noqa: BLE001 return None return await self._attach_root(srv, client, root) if client is not None else None try: client = await self._spawn_client(srv, root, trusted) if client is None: self._mark_broken((srv.server_id, root)) else: with self._state_lock: self._clients[key] = client self._last_used[key] = time.time() eventlog.log_active(srv.server_id, root) spawning.set_result(client) return client finally: with self._state_lock: self._spawning.pop(key, None) @staticmethod async def _attach_root(srv: ServerDef, client: LSPClient, root: str) -> LSPClient: """Multi-root servers: announce ``root`` to the shared process instead of spawning another.""" if srv.multi_root: await client.add_workspace_folder(root) return client async def _spawn_client(self, srv: ServerDef, root: str, trusted: bool) -> Optional[LSPClient]: """Resolve the binary and start a client; ``None`` (after logging) when either fails.""" ctx = ServerContext( workspace_root=root, install_strategy=self._install_strategy, binary_overrides=self._binary_overrides, env_overrides=self._env_overrides, init_overrides=self._init_overrides, trusted=trusted, ) spec = srv.build_spawn(root, ctx) if spec is None: # Binary not locatable (auto-install off, manual-only, or install failed) — surface once. eventlog.log_server_unavailable(srv.server_id, srv.server_id) return None client = LSPClient( server_id=srv.server_id, workspace_root=spec.workspace_root, command=spec.command, env=spec.env, cwd=spec.cwd, initialization_options=spec.initialization_options, seed_diagnostics_on_first_push=spec.seed_diagnostics_on_first_push or srv.seed_first_push, ) try: await client.start() except Exception as e: # noqa: BLE001 eventlog.log_spawn_failed(srv.server_id, root, e) return None return client def _touch(self, client: LSPClient) -> None: """Refresh last-used; guarded on membership so a client reaped mid-operation can't resurrect its entry.""" with self._state_lock: for key, c in self._clients.items(): if c is client: self._last_used[key] = time.time() async def _start_idle_reaper(self) -> None: self._idle_reaper_task = asyncio.create_task(self._idle_reaper_loop()) async def _idle_reaper_loop(self) -> None: interval = min(60.0, self._idle_timeout) while True: await asyncio.sleep(interval) try: await self._reap_idle_once() except asyncio.CancelledError: raise except Exception as e: # noqa: BLE001 # A transient sweep error must not kill the reaper, or the accumulation leak it fixes comes back. logger.debug("LSP idle reaper sweep error: %s", e) async def _reap_idle_once(self) -> None: cutoff = time.time() - self._idle_timeout with self._state_lock: idle_keys = [key for key in self._clients if self._last_used.get(key, 0) < cutoff] clients = [self._clients.pop(key) for key in idle_keys] for key in idle_keys: self._last_used.pop(key, None) if clients: eventlog.log_reaped([(c.server_id, c.workspace_root) for c in clients], self._idle_timeout) await asyncio.gather(*(client.shutdown() for client in clients), return_exceptions=True) # Externally deleted project roots (rm -rf, a worktree removed by another process) never go # idle from the server's point of view — tsserver keeps its multi-GiB heap for a tree that is gone. await self._detach_roots(lambda folder: not os.path.isdir(folder), reason="workspace root deleted") def release_workspace(self, workspace_root: str) -> int: """Shut down the clients serving ``workspace_root`` or any root beneath it; returns how many. Called by the worktree cleanup paths BEFORE ``git worktree remove`` so a gateway that outlives the session does not keep the language server (and its stdio pipes) alive for a tree that no longer exists. Multi-root servers only drop the folder. Idempotent; best-effort — never blocks removal. """ if not self._enabled: return 0 root = os.path.abspath(workspace_root) try: return self._loop.run(self._release_async(root), timeout=15.0) except Exception as e: # noqa: BLE001 logger.debug("LSP release of %s failed: %s", root, e) return 0 async def _release_async(self, root: str) -> int: def _under(path: str) -> bool: return path == root or path.startswith(root + os.sep) # A spawn in flight would insert its client AFTER we detach; let it land first (same loop, so # once the future resolves the client is in ``_clients`` and the pass below sees it). with self._state_lock: pending = [fut for fut in self._spawning.values() if not fut.done()] self._broken = {key: deadline for key, deadline in self._broken.items() if not _under(key[1])} for path in [p for p in self._delta_baseline if _under(p)]: del self._delta_baseline[path] if pending: await asyncio.wait(pending, timeout=10.0) released = await self._detach_roots(_under, reason="workspace released") clear_cache() return released async def _detach_roots(self, is_gone: Callable[[str], bool], *, reason: str) -> int: """Shared teardown primitive for :meth:`release_workspace` and the reaper. Clients whose every workspace folder ``is_gone`` are detached under ``_state_lock`` and shut down; multi-root clients that still serve other folders only drop the gone ones. Returns the number of clients shut down. """ with self._state_lock: dead_keys = [key for key, c in self._clients.items() if all(map(is_gone, c.workspace_folders))] clients = [self._clients.pop(key) for key in dead_keys] for key in dead_keys: self._last_used.pop(key, None) trims = [(c, [f for f in c.workspace_folders if is_gone(f)]) for c in self._clients.values()] for client, folders in trims: for folder in folders: try: await client.remove_workspace_folder(folder) except Exception as e: # noqa: BLE001 logger.debug("LSP folder removal for %s failed: %s", folder, e) if clients: eventlog.log_released([(c.server_id, c.workspace_root) for c in clients], reason) await asyncio.gather(*(client.shutdown() for client in clients), return_exceptions=True) return len(clients) async def _shutdown_async(self) -> None: if (reaper := self._idle_reaper_task) is not None: self._idle_reaper_task = None reaper.cancel() await asyncio.gather(reaper, return_exceptions=True) with self._state_lock: clients = list(self._clients.values()) self._clients.clear() self._broken.clear() self._last_used.clear() await asyncio.gather(*(c.shutdown() for c in clients), return_exceptions=True) __all__ = ["LSPService"]