From ae751f944c0fa748549cae860fac2b4399b7da1f Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Sun, 10 May 2026 16:37:42 +0200 Subject: [PATCH] fix: yaml stdio per-connection, iac catalog path, makefile port - yaml-language-server: rewrite to stdio per WebSocket (fixes crash loop) vscode-jsonrpc v9 createServerSocketTransport is a TCP client, not server now spawns yaml-language-server --stdio per connection via asyncio subprocess - bicep/modules.py: add /iac_source_catalog.json as first path in _IAC_SOURCE_PATHS Dockerfile copies to /iac_source_catalog.json but path wasn't listed - server.py: remove YAML_LSP_PORT daemon (no longer needed with stdio mode) - Makefile: add -e HTTP_PORT=$(HEALTH_PORT) to all docker run commands server defaulted to :8000 but Makefile exposed :2089 with no override --- Makefile | 3 ++ ilsp/bicep_lsp/modules.py | 3 +- ilsp/server.py | 19 ++-------- ilsp/yaml_lsp/proxy.py | 63 +++++++++++++++++++++++---------- pipeline_templates_catalog.json | 2 +- 5 files changed, 52 insertions(+), 38 deletions(-) diff --git a/Makefile b/Makefile index 3e3e671..f6296c2 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,7 @@ run: stop ## Build and start container (rebuilds image) -p $(PYTHON_PORT):$(PYTHON_PORT) \ -p $(BICEP_PORT):$(BICEP_PORT) \ -p $(HEALTH_PORT):$(HEALTH_PORT) \ + -e HTTP_PORT=$(HEALTH_PORT) \ -e DEVOPS_MCP_URL=$(DEVOPS_MCP_URL) \ -e PYTHONUNBUFFERED=1 \ $(IMAGE):$(TAG) @@ -43,6 +44,7 @@ run-quick: ## Start container without rebuilding image -p $(PYTHON_PORT):$(PYTHON_PORT) \ -p $(BICEP_PORT):$(BICEP_PORT) \ -p $(HEALTH_PORT):$(HEALTH_PORT) \ + -e HTTP_PORT=$(HEALTH_PORT) \ -e PYTHONUNBUFFERED=1 \ $(IMAGE):$(TAG) @sleep 5 @@ -55,6 +57,7 @@ run-with-data: ## Start container with local data dir mounted (test volum -p $(PYTHON_PORT):$(PYTHON_PORT) \ -p $(BICEP_PORT):$(BICEP_PORT) \ -p $(HEALTH_PORT):$(HEALTH_PORT) \ + -e HTTP_PORT=$(HEALTH_PORT) \ -e PYTHONUNBUFFERED=1 \ -v "$(DEVOPS_MCP_REPO):/data:ro" \ $(IMAGE):$(TAG) diff --git a/ilsp/bicep_lsp/modules.py b/ilsp/bicep_lsp/modules.py index b6b0e88..588dd65 100644 --- a/ilsp/bicep_lsp/modules.py +++ b/ilsp/bicep_lsp/modules.py @@ -31,7 +31,8 @@ _CATALOG_PATHS = [ # IAC source catalog — richer param descriptions from Bicep source code _IAC_SOURCE_PATHS = [ - pathlib.Path("/data/iac_source_catalog.json"), # volume-mount + pathlib.Path("/iac_source_catalog.json"), # baked into Docker image + pathlib.Path("/data/iac_source_catalog.json"), # volume-mount (future) pathlib.Path(__file__).parent.parent.parent / "iac_source_catalog.json", # dev ] diff --git a/ilsp/server.py b/ilsp/server.py index 0e42b81..baf5d25 100644 --- a/ilsp/server.py +++ b/ilsp/server.py @@ -31,7 +31,6 @@ logger = logging.getLogger(__name__) PYTHON_LSP_PORT = int(os.getenv("PYTHON_LSP_PORT", "2087")) BICEP_LSP_PORT = int(os.getenv("BICEP_LSP_PORT", "2088")) -YAML_LSP_PORT = int(os.getenv("YAML_LSP_PORT", "2090")) HTTP_PORT = int(os.getenv("HTTP_PORT", "8000")) _CHUNK = 65536 @@ -48,20 +47,6 @@ def _serve_python_lsp(port: int) -> None: logger.warning("pylsp exited (code %s) — restarting", proc.returncode) -def _serve_yaml_lsp(port: int) -> None: - """Start yaml-language-server in TCP socket mode; restart on unexpected exit.""" - import shutil - yaml_ls = shutil.which("yaml-language-server") - if not yaml_ls: - logger.warning("yaml-language-server not found — /yaml completions disabled") - return - while True: - proc = subprocess.Popen([yaml_ls, f"--socket={port}"]) - logger.info("yaml-language-server listening on localhost:%d PID=%d", port, proc.pid) - proc.wait() - logger.warning("yaml-language-server exited (code %s) — restarting", proc.returncode) - - async def _pipe(reader: asyncio.StreamReader, writer) -> None: """Pipe bytes from reader to writer until EOF.""" try: @@ -162,7 +147,7 @@ async def _build_app() -> web.Application: return await _ws_proxy(request, "127.0.0.1", BICEP_LSP_PORT) async def yaml_ws(request: web.Request) -> web.WebSocketResponse: - return await yaml_ws_handler(request, YAML_LSP_PORT) + return await yaml_ws_handler(request) app.router.add_get("/health", health) app.router.add_post("/reload", reload) @@ -186,7 +171,7 @@ async def main_async() -> None: # LSP servers run internally on localhost — not exposed outside the container threading.Thread(target=_serve_python_lsp, args=(PYTHON_LSP_PORT,), daemon=True).start() threading.Thread(target=serve_bicep, args=(BICEP_LSP_PORT,), daemon=True).start() - threading.Thread(target=_serve_yaml_lsp, args=(YAML_LSP_PORT,), daemon=True).start() + # yaml-language-server is spawned per-connection in yaml_ws_handler (--stdio mode) app = await _build_app() runner = web.AppRunner(app) diff --git a/ilsp/yaml_lsp/proxy.py b/ilsp/yaml_lsp/proxy.py index 92a2a6a..da475be 100644 --- a/ilsp/yaml_lsp/proxy.py +++ b/ilsp/yaml_lsp/proxy.py @@ -273,31 +273,48 @@ class _YamlSession: # ── Main WS handler ─────────────────────────────────────────────────────────── -async def yaml_ws_handler(request: web.Request, yaml_lsp_port: int) -> web.WebSocketResponse: +async def yaml_ws_handler(request: web.Request, yaml_lsp_port: int = 0) -> web.WebSocketResponse: """ WebSocket handler for the /yaml endpoint. - Bridges the editor WS connection to yaml-language-server TCP, intercepting - completion messages to inject pipeline template completions. + Spawns yaml-language-server --stdio per editor connection (one process per + session). Bridges WS ↔ process stdin/stdout, intercepting completion messages + to inject pipeline template completions. + + Note: yaml_lsp_port is unused — kept for API compatibility. + yaml-language-server uses --stdio so no TCP port is needed. """ + import shutil + ws = web.WebSocketResponse() await ws.prepare(request) - try: - tcp_reader, tcp_writer = await asyncio.wait_for( - asyncio.open_connection("127.0.0.1", yaml_lsp_port), timeout=3.0 - ) - except (OSError, asyncio.TimeoutError) as exc: - logger.error("Cannot connect to yaml-language-server on port %d: %s", yaml_lsp_port, exc) - await ws.close(code=1011, message=b"YAML LSP backend unavailable", timeout=2.0) + yaml_ls = shutil.which("yaml-language-server") + if not yaml_ls: + logger.error("yaml-language-server not found in PATH") + await ws.close(code=1011, message=b"yaml-language-server not installed", timeout=2.0) return ws + try: + proc = await asyncio.create_subprocess_exec( + yaml_ls, "--stdio", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + ) + except Exception as exc: + logger.error("Failed to start yaml-language-server: %s", exc) + await ws.close(code=1011, message=b"YAML LSP failed to start", timeout=2.0) + return ws + + logger.info("yaml-language-server started (--stdio) PID=%d", proc.pid) + session = _YamlSession() ws_buf = _LspFrameBuffer() - tcp_buf = _LspFrameBuffer() + proc_buf = _LspFrameBuffer() async def client_to_server() -> None: - """WS → TCP: track document content and completion requests.""" + """WS → stdin: track document content and completion requests.""" try: async for msg in ws: if msg.type not in (WSMsgType.BINARY, WSMsgType.TEXT): @@ -313,7 +330,6 @@ async def yaml_ws_handler(request: web.Request, yaml_lsp_port: int) -> web.WebSo uri = params.get("textDocument", {}).get("uri", "") text = params.get("textDocument", {}).get("text") or "" if not text: - # didChange has contentChanges changes = params.get("contentChanges", []) if changes: text = changes[-1].get("text", "") @@ -323,21 +339,25 @@ async def yaml_ws_handler(request: web.Request, yaml_lsp_port: int) -> web.WebSo session.record_request(parsed) except Exception: pass - tcp_writer.write(_lsp_frame(frame)) - await tcp_writer.drain() + proc.stdin.write(_lsp_frame(frame)) + await proc.stdin.drain() except Exception: pass finally: - tcp_writer.close() + try: + proc.stdin.close() + except Exception: + pass + proc.kill() async def server_to_client() -> None: - """TCP → WS: inject completions into completion responses.""" + """stdout → WS: inject completions into completion responses.""" try: while True: - data = await tcp_reader.read(_CHUNK) + data = await proc.stdout.read(_CHUNK) if not data: break - frames = tcp_buf.feed(data) + frames = proc_buf.feed(data) for frame in frames: try: parsed = json.loads(frame) @@ -356,4 +376,9 @@ async def yaml_ws_handler(request: web.Request, yaml_lsp_port: int) -> web.WebSo await ws.close() await asyncio.gather(client_to_server(), server_to_client(), return_exceptions=True) + try: + proc.kill() + except Exception: + pass + logger.info("yaml-language-server session ended PID=%d", proc.pid) return ws diff --git a/pipeline_templates_catalog.json b/pipeline_templates_catalog.json index 0954082..1e6c048 100644 --- a/pipeline_templates_catalog.json +++ b/pipeline_templates_catalog.json @@ -1,5 +1,5 @@ { - "synced_at": "2026-05-10T13:54:30.009676+00:00", + "synced_at": "2026-05-10T14:13:29.433739+00:00", "template_count": 48, "templates": { "tasks/semver.yml@pipeline-templates": {