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: