From e2d9cd88211f5083b2ad2b96746f0a7de922578a Mon Sep 17 00:00:00 2001 From: Piak <127805477+piakdev@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:50:00 +0700 Subject: [PATCH] fix(lsp): read1() on the session pipe so short frames are not held in the buffer `LspSession._read_loop` calls `proc.stdout.read(4096)`. `Popen` hands back a `BufferedReader`, and `BufferedReader.read(size)` keeps blocking until it can fill the whole requested buffer or the stream reaches EOF. A language server is a long-lived process, so it never closes stdout. When the server's reply is shorter than 4096 bytes the reader blocks with a complete JSON-RPC frame already sitting in the pipe, while the server waits for the `initialized` notification that only arrives after the client parses the initialize response. Both sides wait for each other until the init timeout fires. Measured against typescript-language-server: 234 bytes of `window/logMessage` plus a 1,814-byte initialize response = 2,048 bytes total, well short of 4096. Every `lsp_query` then reported `available=false` with `latency_ms` pinned to the 20s init timeout and fell back to grep, even though the server had answered in ~0.1s. `read1()` returns after a single underlying pipe read, so a complete frame is delivered as soon as it arrives. `getattr(..., 'read1', ...read)` keeps a fallback for file-like test doubles and unusual Popen configurations. Adds a regression test that spawns a helper process which writes one short initialize response and then sleeps, reproducing the deadlock. It fails on the unpatched reader (init times out) and passes with `read1()`. Verified: backend/tests/unit/utils passes (54 LSP-related tests). The unrelated failures in validation/orchestration/tui reproduce identically on a clean `main` in this environment and are missing pytest plugins, not regressions from this change. --- .../unit/utils/test_lsp_session_reader.py | 44 +++++++++++++++++++ backend/utils/lsp/lsp_session.py | 10 ++++- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 backend/tests/unit/utils/test_lsp_session_reader.py diff --git a/backend/tests/unit/utils/test_lsp_session_reader.py b/backend/tests/unit/utils/test_lsp_session_reader.py new file mode 100644 index 000000000..4a523a5b2 --- /dev/null +++ b/backend/tests/unit/utils/test_lsp_session_reader.py @@ -0,0 +1,44 @@ +"""Regression tests for the persistent LSP session's pipe reader.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from backend.utils.http.stdio_json_rpc import encode_json_rpc_message +from backend.utils.lsp.lsp_project_routing import LspFileContext +from backend.utils.lsp.lsp_session import LspSession + + +def test_lsp_session_reader_delivers_short_frame_without_waiting_for_eof( + tmp_path: Path, +) -> None: + response = encode_json_rpc_message( + { + 'jsonrpc': '2.0', + 'id': 1, + 'result': {'capabilities': {'referencesProvider': True}}, + } + ) + assert len(response) < 4096 + ctx = LspFileContext( + server_name='fake-short-response', + command=( + sys.executable, + '-c', + ( + 'import sys, time; ' + f'sys.stdout.buffer.write({response!r}); ' + 'sys.stdout.buffer.flush(); ' + 'time.sleep(5)' + ), + ), + language_id='python', + workspace_root=tmp_path, + ) + session = LspSession(ctx) + + try: + assert session.ensure_initialized(timeout=1.0) is True + finally: + session.close() diff --git a/backend/utils/lsp/lsp_session.py b/backend/utils/lsp/lsp_session.py index ec7f66cdb..25c0b8602 100644 --- a/backend/utils/lsp/lsp_session.py +++ b/backend/utils/lsp/lsp_session.py @@ -173,8 +173,16 @@ def _read_stdout(self) -> None: if proc is None or proc.stdout is None: return try: + # Popen creates a BufferedReader by default. Keep a read() fallback + # for file-like test doubles and unusual Popen configurations. + read_available = getattr(proc.stdout, 'read1', proc.stdout.read) while proc.poll() is None: - chunk = proc.stdout.read(4096) + # BufferedReader.read(size) tries to fill the whole requested + # buffer. Language servers are long-lived, so a response shorter + # than 4096 bytes neither fills the buffer nor reaches EOF and the + # reader can block forever with a complete JSON-RPC frame waiting + # in the pipe. read1() returns after one underlying pipe read. + chunk = read_available(4096) if not chunk: break with self._lock: