diff --git a/features/lsp_transport_coverage.feature b/features/lsp_transport_coverage.feature index c5c9aaff7..205325fe6 100644 --- a/features/lsp_transport_coverage.feature +++ b/features/lsp_transport_coverage.feature @@ -23,6 +23,13 @@ Feature: LSP StdioTransport coverage When ltcov I try to start the transport Then ltcov the error should be an LspError with message "Failed to start" + Scenario: ltcov start raises LspError when process exits immediately after Popen succeeds + Given ltcov I create a StdioTransport for command "cat" + And ltcov Popen is mocked to return a process that already exited with code 1 + When ltcov I try to start the transport + Then ltcov the error should be an LspError with message "exited immediately" + And ltcov the internal process should be None + # ── stop() paths ──────────────────────────────────────────────── Scenario: ltcov stop returns None when not started diff --git a/features/steps/lsp_transport_coverage_steps.py b/features/steps/lsp_transport_coverage_steps.py index 7a0fefa3b..204d317b8 100644 --- a/features/steps/lsp_transport_coverage_steps.py +++ b/features/steps/lsp_transport_coverage_steps.py @@ -98,6 +98,19 @@ def step_ltcov_popen_oserror(context: Context, msg: str) -> None: context.add_cleanup(patcher.stop) +@given( + "ltcov Popen is mocked to return a process that already exited with code {code:d}" +) +def step_ltcov_popen_exited(context: Context, code: int) -> None: + proc = _make_mock_process(poll_return=code, returncode=code) + patcher = patch( + "cleveragents.lsp.transport.subprocess.Popen", + return_value=proc, + ) + patcher.start() + context.add_cleanup(patcher.stop) + + @given("ltcov the transport has a mock process that already exited with code {code:d}") def step_ltcov_exited_process(context: Context, code: int) -> None: proc = _make_mock_process(poll_return=code, returncode=code) diff --git a/src/cleveragents/lsp/transport.py b/src/cleveragents/lsp/transport.py index 841475b1c..5e833cfa8 100644 --- a/src/cleveragents/lsp/transport.py +++ b/src/cleveragents/lsp/transport.py @@ -18,6 +18,7 @@ and issue #826. from __future__ import annotations +import contextlib import json import os import select @@ -149,6 +150,45 @@ class StdioTransport: command=self._command, ) + # Post-initialization check: if the child has already exited before + # we had a chance to interact with it (e.g. binary cannot be loaded, + # missing shared library, syntax error in script), clean up all pipe + # file descriptors to prevent resource leaks and zombie processes. + if self._process.poll() is not None: + pid = self._process.pid + code = self._process.returncode + logger.warning( + "lsp.transport.early_exit", + pid=pid, + exit_code=code, + ) + # Wait to prevent zombie accumulation. + self._process.wait() + # Close stdin/stdout/stderr if they are still open; the OS may + # have already closed them after the child exited, so we suppress + # OSError on each close. + with contextlib.suppress(OSError): + if self._process.stdin is not None: + self._process.stdin.close() + with contextlib.suppress(OSError): + if self._process.stdout is not None: + self._process.stdout.close() + with contextlib.suppress(OSError): + if self._process.stderr is not None: + self._process.stderr.close() + self._process = None + from cleveragents.lsp.errors import LspError + + raise LspError( + f"LSP server process exited immediately (exit code {code})", + details={ + "command": cmd, + "args": self._args, + "exit_code": code, + "pid": pid, + }, + ) + def stop(self, timeout: float = _GRACEFUL_SHUTDOWN_TIMEOUT) -> int | None: """Terminate the subprocess gracefully, then force-kill if needed.