fix: replace asyncio subprocess proxy with thread-based Popen proxy
Some checks failed
Build and Deploy iLSP / test (push) Failing after 12s
Build and Deploy iLSP / build-and-deploy (push) Has been skipped

asyncio subprocess PIPE unreliable for long-lived stdio bridging. Use Popen + threads instead. Also fix smoke_test.sh stdin handling.
This commit is contained in:
Henrik Jess Nielsen
2026-05-10 13:02:52 +02:00
parent c550a4963e
commit 6385e159ff
5 changed files with 175 additions and 139 deletions

View File

@@ -27,7 +27,7 @@ RUN pip install --upgrade pip build \
# ── Stage 3: final runtime ──────────────────────────────────────────────────── # ── Stage 3: final runtime ────────────────────────────────────────────────────
# Use Microsoft's official .NET runtime image — avoids the SHA1-signed APT key # Use Microsoft's official .NET runtime image — avoids the SHA1-signed APT key
# issue on newer Debian hosts (trixie+ rejects packages.microsoft.com GPG since 2026-02-01). # issue on newer Debian hosts (trixie+ rejects packages.microsoft.com GPG since 2026-02-01).
FROM mcr.microsoft.com/dotnet/runtime:8.0 FROM mcr.microsoft.com/dotnet/runtime:10.0
# Install Python 3 + pip (the dotnet base image is Debian bookworm) # Install Python 3 + pip (the dotnet base image is Debian bookworm)
RUN apt-get update \ RUN apt-get update \

View File

@@ -1,19 +1,23 @@
""" """
Asyncio TCP proxy that wraps Bicep.LangServer.dll. Thread-based TCP proxy that wraps Bicep.LangServer.dll.
Architecture: Architecture:
Editor (TCP:2088) ──► BicepProxy ──► Bicep.LangServer subprocess (stdio) Editor (TCP:2088) ──► BicepProxy ──► Bicep.LangServer subprocess (stdio)
The proxy intercepts textDocument/completion responses and injects Uses subprocess.Popen + threads instead of asyncio subprocess — far more
LRU Bicep module completions with higher sort priority (sortText "0_lru_..."). reliable for stdin/stdout bridging of long-lived processes.
Intercepts textDocument/completion responses and injects LRU Bicep module
completions with higher sort priority (sortText "0_lru_...").
All other LSP messages are forwarded unchanged. All other LSP messages are forwarded unchanged.
""" """
import asyncio
import json import json
import logging import logging
import os import os
import socket
import subprocess import subprocess
import threading
from typing import Any from typing import Any
from .modules import BicepModuleCatalog from .modules import BicepModuleCatalog
@@ -24,102 +28,35 @@ BICEP_LS_PATH = os.getenv(
"BICEP_LS_PATH", "BICEP_LS_PATH",
"/opt/bicep-langserver/Bicep.LangServer.dll", "/opt/bicep-langserver/Bicep.LangServer.dll",
) )
LISTEN_PORT = int(os.getenv("BICEP_LSP_PORT", "2088"))
class _ContentLengthFramer: def _read_message(fileobj) -> bytes:
"""Reads/writes LSP Content-Length framed messages.""" """Read one LSP Content-Length framed message from a file-like object."""
header = b""
def __init__(self, reader: asyncio.StreamReader): while not header.endswith(b"\r\n\r\n"):
self._reader = reader ch = fileobj.read(1)
if not ch:
async def read_message(self) -> bytes: raise EOFError("Stream closed")
headers = b"" header += ch
while not headers.endswith(b"\r\n\r\n"):
chunk = await self._reader.read(1)
if not chunk:
raise EOFError("Connection closed")
headers += chunk
content_length = 0 content_length = 0
for line in headers.split(b"\r\n"): for line in header.split(b"\r\n"):
if line.lower().startswith(b"content-length:"): if line.lower().startswith(b"content-length:"):
content_length = int(line.split(b":")[1].strip()) content_length = int(line.split(b":")[1].strip())
body = await self._reader.readexactly(content_length) return fileobj.read(content_length)
return body
@staticmethod
def frame(body: bytes) -> bytes: def _frame(body: bytes) -> bytes:
return f"Content-Length: {len(body)}\r\n\r\n".encode() + body return f"Content-Length: {len(body)}\r\n\r\n".encode() + body
class BicepProxy: def _inject_completions(msg: dict[str, Any]) -> bytes:
"""Per-connection proxy between one editor client and one Bicep LS process."""
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
self._client_reader = reader
self._client_writer = writer
self._proc: subprocess.Popen | None = None
self._ls_reader: asyncio.StreamReader | None = None
self._ls_writer: asyncio.StreamWriter | None = None
async def run(self) -> None:
peer = self._client_writer.get_extra_info("peername")
logger.info("New Bicep client: %s", peer)
self._proc = await asyncio.create_subprocess_exec(
"dotnet", BICEP_LS_PATH, "--stdio",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._ls_reader = self._proc.stdout
self._ls_writer = self._proc.stdin
try:
await asyncio.gather(
self._client_to_ls(),
self._ls_to_client(),
)
except (EOFError, ConnectionResetError, asyncio.CancelledError):
pass
finally:
self._cleanup()
async def _client_to_ls(self) -> None:
framer = _ContentLengthFramer(self._client_reader)
while True:
body = await framer.read_message()
framed = _ContentLengthFramer.frame(body)
self._ls_writer.write(framed)
await self._ls_writer.drain()
async def _ls_to_client(self) -> None:
framer = _ContentLengthFramer(self._ls_reader)
while True:
body = await framer.read_message()
try:
msg = json.loads(body)
body = self._maybe_inject_completions(msg)
except json.JSONDecodeError:
pass
framed = _ContentLengthFramer.frame(
body if isinstance(body, bytes) else json.dumps(body).encode()
)
self._client_writer.write(framed)
await self._client_writer.drain()
def _maybe_inject_completions(self, msg: dict[str, Any]) -> dict[str, Any] | bytes:
"""Inject LRU modules into completion responses.""" """Inject LRU modules into completion responses."""
result = msg.get("result") result = msg.get("result")
if result is None: if result is None:
return json.dumps(msg).encode() return json.dumps(msg).encode()
# Completion result is either a list or {isIncomplete, items}
items: list | None = None items: list | None = None
if isinstance(result, list): if isinstance(result, list):
items = result items = result
@@ -131,11 +68,9 @@ class BicepProxy:
lru_items = BicepModuleCatalog.as_completion_items() lru_items = BicepModuleCatalog.as_completion_items()
if lru_items: if lru_items:
# Downgrade standard items so LRU sorts first
for item in items: for item in items:
st = item.get("sortText", item.get("label", "")) st = item.get("sortText", item.get("label", ""))
item["sortText"] = f"1_az_{st}" item["sortText"] = f"1_az_{st}"
if isinstance(result, list): if isinstance(result, list):
msg["result"] = lru_items + items msg["result"] = lru_items + items
else: else:
@@ -144,21 +79,108 @@ class BicepProxy:
return json.dumps(msg).encode() return json.dumps(msg).encode()
def _cleanup(self) -> None:
if self._proc and self._proc.returncode is None: def _client_to_ls(
self._proc.terminate() conn_file,
self._client_writer.close() proc_stdin,
) -> None:
try:
while True:
body = _read_message(conn_file)
logger.debug("Client→LS: %d bytes", len(body))
framed = _frame(body)
proc_stdin.write(framed)
proc_stdin.flush()
logger.debug("Client→LS: flushed")
except EOFError:
logger.debug("Client write side closed — signalling EOF to LS")
except Exception as exc:
logger.debug("Client→LS error: %s", exc)
finally:
# Half-close: tell the LS that no more input is coming.
# The LS may still send responses, so we don't kill it here.
try:
proc_stdin.close()
except Exception:
pass
async def serve_bicep(port: int = LISTEN_PORT) -> None: def _ls_to_client(
"""Start the Bicep LSP TCP proxy server.""" proc_stdout,
await BicepModuleCatalog.start_background_refresh() conn: socket.socket,
) -> None:
try:
while True:
body = _read_message(proc_stdout)
logger.debug("LS→Client: %d bytes", len(body))
try:
out = _inject_completions(json.loads(body))
except json.JSONDecodeError:
out = body
conn.sendall(_frame(out))
except EOFError:
logger.debug("Bicep LS stdout closed")
except Exception as exc:
logger.debug("LS→Client error: %s", exc)
async def _handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
proxy = BicepProxy(reader, writer)
await proxy.run()
server = await asyncio.start_server(_handle, "0.0.0.0", port) def _handle_client(conn: socket.socket, addr: tuple) -> None:
logger.info("New Bicep client: %s", addr)
proc = subprocess.Popen(
["dotnet", BICEP_LS_PATH, "--stdio"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
logger.info("Bicep LS subprocess started (pid=%d)", proc.pid)
# Unbuffered read from the socket — critical for correct LSP framing
conn_file = conn.makefile("rb", buffering=0)
# t1: client → LS (finishes when client closes write side)
t1 = threading.Thread(
target=_client_to_ls,
args=(conn_file, proc.stdin),
daemon=True,
)
# t2: LS → client (finishes when LS closes stdout)
t2 = threading.Thread(
target=_ls_to_client,
args=(proc.stdout, conn),
daemon=True,
)
t1.start()
t2.start()
# Session ends when LS is done (not when client closes write side)
t2.join()
try:
proc.wait(timeout=3)
except Exception:
proc.terminate()
try:
conn.close()
except Exception:
pass
logger.info("Bicep client %s disconnected", addr)
def serve_bicep(port: int) -> None:
"""Blocking TCP server — run in a daemon thread alongside asyncio."""
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("0.0.0.0", port))
server.listen(10)
logger.info("Bicep LSP proxy listening on TCP :%d", port) logger.info("Bicep LSP proxy listening on TCP :%d", port)
async with server:
await server.serve_forever() while True:
try:
conn, addr = server.accept()
threading.Thread(
target=_handle_client,
args=(conn, addr),
daemon=True,
).start()
except Exception as exc:
logger.error("Accept error: %s", exc)

View File

@@ -11,6 +11,7 @@ import asyncio
import logging import logging
import os import os
import signal import signal
import threading
from aiohttp import web from aiohttp import web
@@ -58,13 +59,23 @@ async def main_async() -> None:
level=logging.INFO, level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
) )
logging.getLogger("ilsp.bicep_lsp.proxy").setLevel(logging.DEBUG)
# Pre-warm caches # Pre-warm caches
logger.info("Pre-warming catalogs…") logger.info("Pre-warming catalogs…")
from .bicep_lsp.modules import BicepModuleCatalog
await asyncio.gather( await asyncio.gather(
PypiCatalog.start_background_refresh(), PypiCatalog.start_background_refresh(),
BicepModuleCatalog.start_background_refresh(),
) )
# Start Bicep LSP proxy in a daemon thread (blocking socket server)
threading.Thread(
target=serve_bicep,
args=(BICEP_LSP_PORT,),
daemon=True,
).start()
# Build health app # Build health app
health_app = await _health_app() health_app = await _health_app()
runner = web.AppRunner(health_app) runner = web.AppRunner(health_app)
@@ -73,11 +84,8 @@ async def main_async() -> None:
await site.start() await site.start()
logger.info("Health endpoint on http://0.0.0.0:%d/health", HEALTH_PORT) logger.info("Health endpoint on http://0.0.0.0:%d/health", HEALTH_PORT)
# Run all services # Run remaining asyncio services
await asyncio.gather( await _serve_python_lsp(PYTHON_LSP_PORT)
_serve_python_lsp(PYTHON_LSP_PORT),
serve_bicep(BICEP_LSP_PORT),
)
def main() -> None: def main() -> None:

View File

@@ -1,6 +1,6 @@
[build-system] [build-system]
requires = ["setuptools>=68", "wheel"] requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build" build-backend = "setuptools.build_meta"
[project] [project]
name = "ilsp" name = "ilsp"

View File

@@ -13,8 +13,8 @@ HEALTH_PORT="${HEALTH_PORT:-2089}"
PASS=0 PASS=0
FAIL=0 FAIL=0
ok() { echo "$*"; ((PASS++)); } ok() { echo "$*"; PASS=$((PASS+1)); }
fail() { echo "$*"; ((FAIL++)); } fail() { echo "$*"; FAIL=$((FAIL+1)); }
# ── Helper: send LSP initialize and read response ───────────────────────────── # ── Helper: send LSP initialize and read response ─────────────────────────────
@@ -28,8 +28,14 @@ send_lsp_init() {
lsp_check() { lsp_check() {
local name="$1" local name="$1"
local port="$2" local port="$2"
local timeout="${3:-3}"
local response local response
response=$(send_lsp_init "$port" | nc -w 3 "$HOST" "$port" 2>/dev/null || true) # Keep stdin open during LSP server startup: the server must NOT see EOF
# on stdin before it has finished responding (especially slow .NET JIT).
# We send the init message and then sleep for the full timeout so that nc
# keeps the TCP write-side open while reading the server's response.
response=$({ send_lsp_init "$port"; sleep "$timeout"; } \
| nc -w "$timeout" "$HOST" "$port" 2>/dev/null || true)
if echo "$response" | grep -q '"result"'; then if echo "$response" | grep -q '"result"'; then
ok "$name LSP responded to initialize (port $port)" ok "$name LSP responded to initialize (port $port)"
else else
@@ -65,11 +71,11 @@ else
echo " ⚠ nc not found — skipping TCP tests" echo " ⚠ nc not found — skipping TCP tests"
fi fi
# 3. Bicep LSP # 3. Bicep LSP (longer timeout — .NET startup takes a few seconds)
echo "" echo ""
echo "Bicep LSP (TCP :$BICEP_PORT)" echo "Bicep LSP (TCP :$BICEP_PORT)"
if command -v nc &>/dev/null; then if command -v nc &>/dev/null; then
lsp_check "Bicep" "$BICEP_PORT" lsp_check "Bicep" "$BICEP_PORT" 12
fi fi
# ── Summary ─────────────────────────────────────────────────────────────────── # ── Summary ───────────────────────────────────────────────────────────────────