fix(lsp): cleanup subprocess on failed initialization in StdioTransport.start() #11203

Closed
HAL9000 wants to merge 4 commits from bugfix/m3-cleanup-subprocess-on-failed-init into master
3 changed files with 60 additions and 0 deletions
+7
View File
@@ -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
@@ -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)
+40
View File
@@ -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.