17e7507a4d
CI / lint (pull_request) Successful in 20s
CI / typecheck (pull_request) Successful in 47s
CI / quality (pull_request) Successful in 37s
CI / security (pull_request) Successful in 1m0s
CI / build (pull_request) Successful in 27s
CI / helm (pull_request) Successful in 24s
CI / unit_tests (pull_request) Successful in 6m50s
CI / e2e_tests (pull_request) Successful in 16m59s
CI / coverage (pull_request) Successful in 10m54s
CI / docker (pull_request) Successful in 1m39s
CI / integration_tests (pull_request) Successful in 23m0s
CI / status-check (pull_request) Successful in 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 56m47s
Refactored restart_server() to use the same 3-phase lock pattern already employed by start_server(), eliminating a deadlock hazard that occurred when the method held _lock across blocking I/O operations (transport.stop(), transport.start(), and client.initialize()). Phase 1 (short lock): reads current state, snapshots required fields, and removes the old server entry so concurrent callers see the server as absent during the restart window. Phase 2 (no lock): stops the old transport, spawns the new process, and performs the LSP handshake — all without holding _lock. These operations can block for up to 60 seconds; holding the lock here would starve any concurrent health_check(), list_running(), or stop_server() calls. Phase 3 (short lock): commits the new _ManagedServer into shared state, preserving the original ref_count. Three new BDD scenarios added to features/lsp_lifecycle_coverage.feature: - health_check is not blocked while restart_server is in progress (uses a threading.Barrier to synchronise the two threads at the exact moment the lock is released) - restart_server does not hold the lock during client.initialize (inspects lock state from within the mock initialize() side-effect) - restart_server preserves the ref_count of the managed server ISSUES CLOSED: #3026
212 lines
10 KiB
Gherkin
212 lines
10 KiB
Gherkin
Feature: LSP Lifecycle Manager coverage
|
|
Exercise every code path in cleveragents.lsp.lifecycle to reach full
|
|
branch and line coverage for the LspLifecycleManager and _ManagedServer
|
|
classes.
|
|
|
|
Background:
|
|
Given llcov a fresh LspLifecycleManager instance
|
|
|
|
# ---------- start_server: brand-new server (happy path) ----------
|
|
|
|
Scenario: Start a new LSP server successfully
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
When llcov I start the server for workspace "/tmp/ws"
|
|
Then llcov the returned client should not be None
|
|
And llcov the server "local/pyright" should be registered
|
|
|
|
# ---------- start_server: reuse existing alive server ----------
|
|
|
|
Scenario: Reuse an existing alive server increments ref count
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov the server is already started for workspace "/tmp/ws"
|
|
When llcov I start the server again for workspace "/tmp/ws"
|
|
Then llcov the returned client should be the same as the original
|
|
And llcov the ref count for "local/pyright" should be 2
|
|
|
|
# ---------- start_server: initialize failure rolls back ----------
|
|
|
|
Scenario: Start server rolls back transport when initialize fails
|
|
Given llcov a server config named "local/broken" with command "broken-server"
|
|
And llcov the LspClient initialize will raise an error
|
|
When llcov I attempt to start the server for workspace "/tmp/ws"
|
|
Then llcov a start error should have been raised
|
|
And llcov the transport should have been stopped after init failure
|
|
|
|
# ---------- start_server: race condition (another thread wins) ----------
|
|
|
|
Scenario: Start server detects a race condition and reuses the winner
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov another thread already registered "local/pyright" before Phase 3
|
|
When llcov I start the server for workspace "/tmp/ws" with race
|
|
Then llcov the returned client should be the race winner client
|
|
And llcov the losing transport should have been stopped
|
|
|
|
# ---------- stop_server: decrement but still > 0 ----------
|
|
|
|
Scenario: Stop server decrements ref count but keeps server alive
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov the server is already started for workspace "/tmp/ws"
|
|
And llcov the server is started again to bump ref count
|
|
When llcov I stop the server "local/pyright"
|
|
Then llcov the server "local/pyright" should still be registered
|
|
And llcov the ref count for "local/pyright" should be 1
|
|
|
|
# ---------- stop_server: last reference, full shutdown ----------
|
|
|
|
Scenario: Stop server shuts down when last reference is released
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov the server is already started for workspace "/tmp/ws"
|
|
When llcov I stop the server "local/pyright"
|
|
Then llcov the server "local/pyright" should not be registered
|
|
And llcov shutdown should have been called on the client
|
|
|
|
# ---------- stop_server: server not found ----------
|
|
|
|
Scenario: Stop server raises error for unknown server
|
|
When llcov I attempt to stop server "local/unknown"
|
|
Then llcov a LspServerNotFoundError should have been raised
|
|
|
|
# ---------- _shutdown_server: graceful shutdown ----------
|
|
|
|
Scenario: Shutdown server calls client shutdown and transport stop
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov the server is already started for workspace "/tmp/ws"
|
|
When llcov I trigger shutdown on the managed server
|
|
Then llcov the client shutdown method should have been called
|
|
And llcov the transport stop method should have been called
|
|
|
|
# ---------- _shutdown_server: shutdown exception is caught ----------
|
|
|
|
Scenario: Shutdown server catches exception from client shutdown
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov the server is already started for workspace "/tmp/ws"
|
|
And llcov the client shutdown will raise an exception
|
|
When llcov I trigger shutdown on the managed server
|
|
Then llcov the transport stop method should have been called despite error
|
|
|
|
# ---------- get_client: found ----------
|
|
|
|
Scenario: Get client returns the client for a running server
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov the server is already started for workspace "/tmp/ws"
|
|
When llcov I get the client for "local/pyright"
|
|
Then llcov the returned client should not be None
|
|
|
|
# ---------- get_client: not found ----------
|
|
|
|
Scenario: Get client raises error for unknown server
|
|
When llcov I attempt to get the client for "local/missing"
|
|
Then llcov a LspServerNotFoundError should have been raised
|
|
|
|
# ---------- health_check: server alive ----------
|
|
|
|
Scenario: Health check returns true for alive server
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov the server is already started for workspace "/tmp/ws"
|
|
When llcov I health check "local/pyright"
|
|
Then llcov the health check result should be true
|
|
|
|
# ---------- health_check: server not registered ----------
|
|
|
|
Scenario: Health check returns false for unknown server
|
|
When llcov I health check "local/nonexistent"
|
|
Then llcov the health check result should be false
|
|
|
|
# ---------- restart_server: happy path ----------
|
|
|
|
Scenario: Restart server stops old transport and creates new client
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov the server is already started for workspace "/tmp/ws"
|
|
When llcov I restart the server "local/pyright"
|
|
Then llcov the returned client should not be None
|
|
And llcov the old transport should have been stopped
|
|
And llcov the managed server should have the new transport and client
|
|
|
|
# ---------- restart_server: not found ----------
|
|
|
|
Scenario: Restart server raises error for unknown server
|
|
When llcov I attempt to restart server "local/ghost"
|
|
Then llcov a LspServerNotFoundError should have been raised
|
|
|
|
# ---------- restart_server: init failure during restart ----------
|
|
|
|
Scenario: Restart server stops new transport when re-initialize fails
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov the server is already started for workspace "/tmp/ws"
|
|
And llcov the LspClient initialize will raise on restart
|
|
When llcov I attempt to restart server "local/pyright"
|
|
Then llcov a restart error should have been raised
|
|
And llcov the new transport should have been stopped after restart failure
|
|
|
|
# ---------- stop_all: multiple servers ----------
|
|
|
|
Scenario: Stop all shuts down every registered server
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov the server is already started for workspace "/tmp/ws"
|
|
Given llcov a server config named "local/ruff" with command "ruff-langserver"
|
|
And llcov the second server is already started for workspace "/tmp/ws2"
|
|
When llcov I stop all servers
|
|
Then llcov no servers should be registered
|
|
|
|
# ---------- stop_all: with shutdown error ----------
|
|
|
|
Scenario: Stop all handles shutdown errors gracefully
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov the server is already started for workspace "/tmp/ws"
|
|
And llcov the client shutdown will raise an exception
|
|
When llcov I stop all servers
|
|
Then llcov no servers should be registered
|
|
|
|
# ---------- stop_all: transport.stop() raises (covers lines 273-277) ----------
|
|
|
|
Scenario: Stop all catches transport stop error in shutdown
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov the server is already started for workspace "/tmp/ws"
|
|
And llcov the transport stop will raise an exception
|
|
When llcov I stop all servers
|
|
Then llcov no servers should be registered
|
|
|
|
# ---------- list_running: with servers ----------
|
|
|
|
Scenario: List running returns status for all servers
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov the server is already started for workspace "/tmp/ws"
|
|
When llcov I list running servers
|
|
Then llcov the running list should contain 1 entry
|
|
And llcov the running entry for "local/pyright" should have workspace "/tmp/ws"
|
|
And llcov the running entry for "local/pyright" should have alive status true
|
|
And llcov the running entry for "local/pyright" should have ref count 1
|
|
|
|
# ---------- list_running: empty ----------
|
|
|
|
Scenario: List running returns empty list when no servers
|
|
When llcov I list running servers
|
|
Then llcov the running list should contain 0 entries
|
|
|
|
# ---------- restart_server: 3-phase lock — health_check not blocked ----------
|
|
|
|
Scenario: health_check is not blocked while restart_server is in progress
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov the server is already started for workspace "/tmp/ws"
|
|
When llcov I restart the server concurrently and health_check runs during blocking IO
|
|
Then llcov health_check should have completed without blocking
|
|
And llcov the restarted client should not be None
|
|
|
|
# ---------- restart_server: 3-phase lock — lock not held during initialize ----------
|
|
|
|
Scenario: restart_server does not hold the lock during client.initialize
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov the server is already started for workspace "/tmp/ws"
|
|
When llcov I restart the server and verify lock is free during initialize
|
|
Then llcov the lock should have been acquirable during initialize
|
|
And llcov the restarted client should not be None
|
|
|
|
# ---------- restart_server: ref_count preserved after restart ----------
|
|
|
|
Scenario: restart_server preserves the ref_count of the managed server
|
|
Given llcov a server config named "local/pyright" with command "pyright-langserver"
|
|
And llcov the server is already started for workspace "/tmp/ws"
|
|
And llcov the server is started again to bump ref count
|
|
When llcov I restart the server "local/pyright"
|
|
Then llcov the ref count for "local/pyright" should be 2
|