From fc40157a775e7c56503f7c1bb1ef1d28e72db427 Mon Sep 17 00:00:00 2001 From: Henrik Jess Nielsen Date: Mon, 11 May 2026 12:29:33 +0200 Subject: [PATCH] add lsp_bridge.py: proper LSP-aware WebSocket bridge replacing websocat websocat reads stdin line-by-line and waits for \n before sending each chunk. LSP JSON bodies have no trailing \n, causing a deadlock: websocat holds the body, the server waits for the body, LSP4IJ waits for the response. lsp_bridge.py reads complete Content-Length-framed messages before sending, then sends each as a single binary WebSocket frame. This fixes autocomplete in IntelliJ IDEA via LSP4IJ. --- scripts/lsp_bridge.py | 93 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100755 scripts/lsp_bridge.py diff --git a/scripts/lsp_bridge.py b/scripts/lsp_bridge.py new file mode 100755 index 0000000..f68b2ed --- /dev/null +++ b/scripts/lsp_bridge.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +LSP WebSocket bridge: stdin/stdout (LSP Content-Length framed) <-> WebSocket. + +websocat reads stdin line-by-line and holds the JSON body until a trailing \\n +arrives -- causing a deadlock on the first LSP message. This script solves that +by reading complete Content-Length-framed messages from stdin before sending. + +Usage: + lsp_bridge.py + +The bridge is transparent: it reads exactly one LSP message from stdin (header + +body), sends it as a single binary WebSocket frame, and writes any incoming +frames straight to stdout. +""" + +import asyncio +import ssl +import sys + +import websockets + + +def _parse_header(buf: bytes) -> tuple[int, int]: + """Return (content_length, body_start_offset) or (-1, -1) if header incomplete.""" + end = buf.find(b"\r\n\r\n") + if end == -1: + return -1, -1 + content_length = 0 + for line in buf[:end].split(b"\r\n"): + if line.lower().startswith(b"content-length:"): + content_length = int(line.split(b":")[1].strip()) + return content_length, end + 4 + + +async def _read_stdin_lsp(reader: asyncio.StreamReader) -> bytes | None: + """Read one complete LSP-framed message from the async stdin reader.""" + buf = b"" + content_length = -1 + body_start = -1 + + while True: + chunk = await reader.read(4096) + if not chunk: + return None + buf += chunk + + if content_length == -1: + content_length, body_start = _parse_header(buf) + + if content_length != -1 and len(buf) >= body_start + content_length: + return buf[: body_start + content_length] + + +async def main(uri: str) -> None: + ssl_ctx = ssl.create_default_context() if uri.startswith("wss://") else None + + loop = asyncio.get_event_loop() + + stdin_reader = asyncio.StreamReader() + await loop.connect_read_pipe( + lambda: asyncio.StreamReaderProtocol(stdin_reader), sys.stdin.buffer + ) + + async with websockets.connect(uri, ssl=ssl_ctx) as ws: + + async def stdin_to_ws() -> None: + try: + while True: + msg = await _read_stdin_lsp(stdin_reader) + if msg is None: + break + await ws.send(msg) + except Exception: + pass + + async def ws_to_stdout() -> None: + try: + async for frame in ws: + data = frame if isinstance(frame, bytes) else frame.encode() + sys.stdout.buffer.write(data) + sys.stdout.buffer.flush() + except Exception: + pass + + await asyncio.gather(stdin_to_ws(), ws_to_stdout()) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: lsp_bridge.py ", file=sys.stderr) + sys.exit(1) + asyncio.run(main(sys.argv[1]))