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
This commit is contained in:
3
Makefile
3
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)
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user