Files
cleveragents-core/features/lsp_transport_coverage.feature
HAL9000 c8232c17f4 fix(lsp): cleanup subprocess on failed initialization in StdioTransport.start()
When subprocess.Popen() fails during LSP server initialization (e.g.
FileNotFoundError for missing command or general OSError), partially-allocated
pipe resources and internal file descriptors could be left in an inconsistent
state. This was caused by _process potentially containing a stale reference if
an exception occurred between pipe allocation and the Popen object being fully
returned.

Fix: Add explicit self._process = None resets in three places:
1) Before subprocess.Popen() — ensures clean initial state even across retries
2) In FileNotFoundError handler — guards against intermediate error states
3) In OSError handler — general safety net for all subprocess failures

This prevents:
- Orphaned child processes never terminated (zombie processes)
- File descriptor leaks from partially-allocated pipes
- Transports stuck in ambiguous 'started but not live' state

Tests added: Two new Behave scenarios verify _process is None after both
FileNotFoundError and OSError during start().

ISSUES CLOSED: #10597
2026-06-13 16:10:40 -04:00

191 lines
9.5 KiB
Gherkin

Feature: LSP StdioTransport coverage
As a developer maintaining the LSP transport layer
I need thorough tests for StdioTransport
So that all error paths and normal flows are exercised
# start() error paths
@tdd_issue @tdd_issue_7044
Scenario: ltcov start cleans up subprocess when post-Popen init fails
Given ltcov I create a StdioTransport for command "cat"
And ltcov Popen is mocked to succeed with a running process
And ltcov logger.info after spawn raises RuntimeError
When ltcov I try to start the transport
Then ltcov an error occurred during start()
And ltcov the internal process should be None
And ltcov the mock process should have been terminated
And ltcov the mock process should have been waited on
Scenario: ltcov start raises RuntimeError when already alive
Given ltcov I create a StdioTransport for command "cat"
And ltcov the transport has a running mock process
When ltcov I try to start the transport
Then ltcov the error should be a RuntimeError with message "already started"
@tdd_issue @tdd_issue_7044
Scenario: ltcov start is alive after successful spawn
Given ltcov I create a StdioTransport for command "cat"
When ltcov I try to start the transport
Then ltcov the transport should be alive
And ltcov the internal process should not be None
Scenario: ltcov is_alive returns False when no process
Given ltcov I create a StdioTransport for command "cat"
When ltcov I check if the transport is alive
Then ltcov the is_alive result should be false
Scenario: ltcov start raises LspError on FileNotFoundError
Given ltcov I create a StdioTransport for command "nonexistent_binary_xyz"
And ltcov Popen is mocked to raise FileNotFoundError
When ltcov I try to start the transport
Then ltcov the error should be an LspError with message "not found"
Scenario: ltcov start raises LspError on OSError
Given ltcov I create a StdioTransport for command "bad_command"
And ltcov Popen is mocked to raise OSError with "Permission denied"
When ltcov I try to start the transport
Then ltcov the error should be an LspError with message "Failed to start"
# ── stop() paths ────────────────────────────────────────────────
Scenario: ltcov stop returns None when not started
Given ltcov I create a StdioTransport for command "echo"
When ltcov I stop the transport
Then ltcov the stop result should be None
Scenario: ltcov stop returns exit code when process already exited
Given ltcov I create a StdioTransport for command "echo"
And ltcov the transport has a mock process that already exited with code 0
When ltcov I stop the transport
Then ltcov the stop result should be 0
And ltcov the internal process should be None
Scenario: ltcov stop returns exit code when process already exited with error
Given ltcov I create a StdioTransport for command "echo"
And ltcov the transport has a mock process that already exited with code 1
When ltcov I stop the transport
Then ltcov the stop result should be 1
Scenario: ltcov stop force kills on timeout
Given ltcov I create a StdioTransport for command "sleep"
And ltcov the transport has a mock process that ignores terminate
When ltcov I stop the transport with timeout 0.1
Then ltcov the mock process should have been killed
And ltcov the stop result should be an integer
# ── send_message() paths ────────────────────────────────────────
Scenario: ltcov send_message raises RuntimeError when not started
Given ltcov I create a StdioTransport for command "echo"
When ltcov I try to send a message
Then ltcov the error should be a RuntimeError with message "not started"
Scenario: ltcov send_message raises RuntimeError when stdin is None
Given ltcov I create a StdioTransport for command "echo"
And ltcov the transport has a mock process with no stdin
When ltcov I try to send a message
Then ltcov the error should be a RuntimeError with message "not started"
Scenario: ltcov send_message writes Content-Length framed JSON-RPC
Given ltcov I create a StdioTransport for command "echo"
And ltcov the transport has a mock process with writable stdin
When ltcov I send the message {"jsonrpc":"2.0","method":"initialize","id":1}
Then ltcov the written bytes should contain "Content-Length:"
And ltcov the written bytes should contain "initialize"
Scenario: ltcov send_message raises BrokenPipeError on dead process
Given ltcov I create a StdioTransport for command "echo"
And ltcov the transport has a mock process with broken stdin
When ltcov I try to send a message
Then ltcov the error should be a BrokenPipeError
# ── read_message() paths ────────────────────────────────────────
Scenario: ltcov read_message raises RuntimeError when not started
Given ltcov I create a StdioTransport for command "echo"
When ltcov I try to read a message
Then ltcov the error should be a RuntimeError with message "not started"
Scenario: ltcov read_message raises RuntimeError when stdout is None
Given ltcov I create a StdioTransport for command "echo"
And ltcov the transport has a mock process with no stdout
When ltcov I try to read a message
Then ltcov the error should be a RuntimeError with message "not started"
Scenario: ltcov read_message returns None on select timeout
Given ltcov I create a StdioTransport for command "echo"
And ltcov the transport has a mock process with empty stdout
And ltcov select is mocked to return not ready
When ltcov I read a message with timeout 1.0
Then ltcov the read result should be None
Scenario: ltcov read_message returns None on EOF
Given ltcov I create a StdioTransport for command "echo"
And ltcov the transport has a mock process with stdout that returns EOF
When ltcov I read a message with timeout 1.0
Then ltcov the read result should be None
Scenario: ltcov read_message uses default timeout when None
Given ltcov I create a StdioTransport for command "echo"
And ltcov the transport has a mock process with stdout that returns EOF
When ltcov I read a message with default timeout
Then ltcov the read result should be None
Scenario: ltcov read_message parses valid Content-Length message
Given ltcov I create a StdioTransport for command "echo"
And ltcov the transport has a mock process with a valid JSON-RPC response
When ltcov I read a message with timeout 5.0
Then ltcov the read result should have key "jsonrpc" with value "2.0"
Scenario: ltcov read_message returns None on invalid content-length value
Given ltcov I create a StdioTransport for command "echo"
And ltcov the transport has a mock process with invalid content-length header
When ltcov I read a message with timeout 5.0
Then ltcov the read result should be None
Scenario: ltcov read_message returns None when no content-length header
Given ltcov I create a StdioTransport for command "echo"
And ltcov the transport has a mock process with no content-length header
When ltcov I read a message with timeout 5.0
Then ltcov the read result should be None
Scenario: ltcov read_message raises ValueError on oversized message
Given ltcov I create a StdioTransport for command "echo"
And ltcov the transport has a mock process with oversized content-length
When ltcov I try to read a message
Then ltcov the error should be a ValueError with message "too large"
Scenario: ltcov read_message returns None on body select timeout
Given ltcov I create a StdioTransport for command "echo"
And ltcov the transport has a mock process with headers but body times out
When ltcov I read a message with timeout 5.0
Then ltcov the read result should be None
Scenario: ltcov read_message returns None on truncated body
Given ltcov I create a StdioTransport for command "echo"
And ltcov the transport has a mock process with truncated body
When ltcov I read a message with timeout 5.0
Then ltcov the read result should be None
Scenario: ltcov read_message returns None on invalid JSON body
Given ltcov I create a StdioTransport for command "echo"
And ltcov the transport has a mock process with invalid JSON body
When ltcov I read a message with timeout 5.0
Then ltcov the read result should be None
# ── subprocess cleanup on failed initialization (issue #10597) ────────────
Scenario: ltcov _process is reset to None after FileNotFoundError in start
Given ltcov I create a StdioTransport for command "nonexistent_binary_xyz"
And ltcov Popen is mocked to raise FileNotFoundError
When ltcov I try to start the transport
Then ltcov the error should be an LspError with message "not found"
And ltcov _process must be None after start failure
Scenario: ltcov _process is reset to None after OSError in start
Given ltcov I create a StdioTransport for command "bad_command"
And ltcov Popen is mocked to raise OSError with "Permission denied"
When ltcov I try to start the transport
Then ltcov the error should be an LspError with message "Failed to start"
And ltcov _process must be None after start failure