"""Step definitions for lsp_lifecycle_coverage.feature. Exercises every uncovered code path in cleveragents.lsp.lifecycle including _ManagedServer construction, start/stop/restart lifecycle, race conditions, health checks, error handling, stop_all, and list_running. Also covers the 3-phase lock pattern in restart_server() to verify that health_check() and other operations are not blocked during blocking I/O. All external I/O (StdioTransport, LspClient) is mocked so no real subprocesses are spawned. """ from __future__ import annotations import threading from unittest.mock import MagicMock, patch from behave import given, then, when from behave.runner import Context from cleveragents.lsp.errors import LspServerNotFoundError from cleveragents.lsp.lifecycle import LspLifecycleManager, _ManagedServer from cleveragents.lsp.models import LspServerConfig # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_config(name: str, command: str) -> LspServerConfig: """Create a minimal LspServerConfig for testing.""" return LspServerConfig(name=name, command=command) def _make_mock_transport(alive: bool = True) -> MagicMock: """Return a mock StdioTransport that claims to be alive.""" t = MagicMock() t.is_alive = alive t.start = MagicMock() t.stop = MagicMock() return t def _make_mock_client(initialized: bool = True) -> MagicMock: """Return a mock LspClient.""" c = MagicMock() c.is_initialized = initialized c.initialize = MagicMock() c.shutdown = MagicMock() return c def _patch_transport_and_client(context: Context, transport=None, client=None): """Patch StdioTransport and LspClient constructors. Returns (mock_transport_instance, mock_client_instance). """ mock_transport = transport or _make_mock_transport() mock_client = client or _make_mock_client() p_transport = patch( "cleveragents.lsp.lifecycle.StdioTransport", return_value=mock_transport, ) p_client = patch( "cleveragents.lsp.lifecycle.LspClient", return_value=mock_client, ) p_transport.start() p_client.start() context.add_cleanup(p_transport.stop) context.add_cleanup(p_client.stop) return mock_transport, mock_client class _RaceInjectingDict(dict): """A dict subclass that injects a race winner on the second .get() call for a particular key, simulating another thread registering a server between Phase 1 and Phase 3 of start_server. """ def __init__(self, *args, target_key=None, race_winner=None, **kwargs): super().__init__(*args, **kwargs) self._target_key = target_key self._race_winner = race_winner self._get_count = 0 def get(self, key, default=None): result = super().get(key, default) if key == self._target_key: self._get_count += 1 if self._get_count == 2: # Simulate another thread having registered this key self[key] = self._race_winner return self._race_winner return result # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("llcov a fresh LspLifecycleManager instance") def step_fresh_manager(context: Context) -> None: context.llcov_manager = LspLifecycleManager() context.llcov_config: LspServerConfig | None = None context.llcov_config2: LspServerConfig | None = None context.llcov_client = None context.llcov_original_client = None context.llcov_error = None context.llcov_result = None context.llcov_health = None context.llcov_running_list = None context.llcov_mock_transport = None context.llcov_mock_client = None context.llcov_old_transport = None context.llcov_race_transport = None context.llcov_race_client = None context.llcov_new_transport_stopped = False # --------------------------------------------------------------------------- # Config creation # --------------------------------------------------------------------------- @given('llcov a server config named "{name}" with command "{command}"') def step_create_config(context: Context, name: str, command: str) -> None: cfg = _make_config(name, command) # If first config already set, store as config2 if context.llcov_config is not None: context.llcov_config2 = cfg else: context.llcov_config = cfg # --------------------------------------------------------------------------- # Start server (happy path) # --------------------------------------------------------------------------- @given('llcov the server is already started for workspace "{workspace}"') def step_server_already_started(context: Context, workspace: str) -> None: mock_transport, mock_client = _patch_transport_and_client(context) context.llcov_mock_transport = mock_transport context.llcov_mock_client = mock_client context.llcov_original_client = context.llcov_manager.start_server( context.llcov_config, workspace ) @given('llcov the second server is already started for workspace "{workspace}"') def step_second_server_already_started(context: Context, workspace: str) -> None: mock_transport2 = _make_mock_transport() mock_client2 = _make_mock_client() p_transport = patch( "cleveragents.lsp.lifecycle.StdioTransport", return_value=mock_transport2, ) p_client = patch( "cleveragents.lsp.lifecycle.LspClient", return_value=mock_client2, ) p_transport.start() p_client.start() context.add_cleanup(p_transport.stop) context.add_cleanup(p_client.stop) context.llcov_manager.start_server(context.llcov_config2, workspace) @when('llcov I start the server for workspace "{workspace}"') def step_start_server(context: Context, workspace: str) -> None: mock_transport, mock_client = _patch_transport_and_client(context) context.llcov_mock_transport = mock_transport context.llcov_mock_client = mock_client context.llcov_client = context.llcov_manager.start_server( context.llcov_config, workspace ) # --------------------------------------------------------------------------- # Start server: reuse existing alive server # --------------------------------------------------------------------------- @given("llcov the server is started again to bump ref count") def step_bump_ref_count(context: Context) -> None: """Start the server a second time to bump ref_count to 2.""" context.llcov_manager.start_server(context.llcov_config, "/tmp/ws") @when('llcov I start the server again for workspace "{workspace}"') def step_start_server_again(context: Context, workspace: str) -> None: # The existing transport is still alive, so Phase 1 should return existing client context.llcov_client = context.llcov_manager.start_server( context.llcov_config, workspace ) # --------------------------------------------------------------------------- # Start server: initialize failure # --------------------------------------------------------------------------- @given("llcov the LspClient initialize will raise an error") def step_client_init_will_fail(context: Context) -> None: context.llcov_init_fails = True @when('llcov I attempt to start the server for workspace "{workspace}"') def step_attempt_start_server(context: Context, workspace: str) -> None: mock_transport = _make_mock_transport() mock_client = _make_mock_client() mock_client.initialize.side_effect = RuntimeError("init boom") p_transport = patch( "cleveragents.lsp.lifecycle.StdioTransport", return_value=mock_transport, ) p_client = patch( "cleveragents.lsp.lifecycle.LspClient", return_value=mock_client, ) p_transport.start() p_client.start() context.add_cleanup(p_transport.stop) context.add_cleanup(p_client.stop) context.llcov_mock_transport = mock_transport context.llcov_mock_client = mock_client try: context.llcov_client = context.llcov_manager.start_server( context.llcov_config, workspace ) except Exception as exc: context.llcov_error = exc # --------------------------------------------------------------------------- # Start server: race condition # --------------------------------------------------------------------------- @given('llcov another thread already registered "{name}" before Phase 3') def step_setup_race_condition(context: Context, name: str) -> None: """Pre-build a race winner that will be injected into _servers.""" context.llcov_race_transport = _make_mock_transport(alive=True) context.llcov_race_client = _make_mock_client() @when('llcov I start the server for workspace "{workspace}" with race') def step_start_server_with_race(context: Context, workspace: str) -> None: """Simulate start_server where another thread wins the race in Phase 3. We replace ``manager._servers`` with a ``_RaceInjectingDict`` that injects the race winner on the second ``.get()`` call (Phase 3), while the first ``.get()`` (Phase 1) returns None normally. """ phase2_transport = _make_mock_transport(alive=True) phase2_client = _make_mock_client() race_winner = _ManagedServer( config=context.llcov_config, transport=context.llcov_race_transport, client=context.llcov_race_client, workspace_path=workspace, ) # Replace _servers with our race-injecting dict race_dict = _RaceInjectingDict( target_key=context.llcov_config.name, race_winner=race_winner, ) context.llcov_manager._servers = race_dict p_transport = patch( "cleveragents.lsp.lifecycle.StdioTransport", return_value=phase2_transport, ) p_client = patch( "cleveragents.lsp.lifecycle.LspClient", return_value=phase2_client, ) p_transport.start() p_client.start() context.add_cleanup(p_transport.stop) context.add_cleanup(p_client.stop) context.llcov_client = context.llcov_manager.start_server( context.llcov_config, workspace ) context.llcov_phase2_transport = phase2_transport # --------------------------------------------------------------------------- # Stop server # --------------------------------------------------------------------------- @when('llcov I stop the server "{name}"') def step_stop_server(context: Context, name: str) -> None: context.llcov_manager.stop_server(name) @when('llcov I attempt to stop server "{name}"') def step_attempt_stop_server(context: Context, name: str) -> None: try: context.llcov_manager.stop_server(name) except Exception as exc: context.llcov_error = exc # --------------------------------------------------------------------------- # Shutdown (direct call) # --------------------------------------------------------------------------- @given("llcov the client shutdown will raise an exception") def step_client_shutdown_will_fail(context: Context) -> None: context.llcov_mock_client.shutdown.side_effect = RuntimeError("shutdown boom") @given("llcov the transport stop will raise an exception") def step_transport_stop_will_fail(context: Context) -> None: context.llcov_mock_transport.stop.side_effect = RuntimeError("transport stop boom") @when("llcov I trigger shutdown on the managed server") def step_trigger_shutdown(context: Context) -> None: name = context.llcov_config.name managed = context.llcov_manager._servers[name] context.llcov_manager._shutdown_server(managed) # --------------------------------------------------------------------------- # Get client # --------------------------------------------------------------------------- @when('llcov I get the client for "{name}"') def step_get_client(context: Context, name: str) -> None: context.llcov_client = context.llcov_manager.get_client(name) @when('llcov I attempt to get the client for "{name}"') def step_attempt_get_client(context: Context, name: str) -> None: try: context.llcov_client = context.llcov_manager.get_client(name) except Exception as exc: context.llcov_error = exc # --------------------------------------------------------------------------- # Health check # --------------------------------------------------------------------------- @when('llcov I health check "{name}"') def step_health_check(context: Context, name: str) -> None: context.llcov_health = context.llcov_manager.health_check(name) # --------------------------------------------------------------------------- # Restart server # --------------------------------------------------------------------------- @when('llcov I restart the server "{name}"') def step_restart_server(context: Context, name: str) -> None: context.llcov_old_transport = context.llcov_mock_transport new_transport = _make_mock_transport() new_client = _make_mock_client() context.llcov_new_transport = new_transport context.llcov_new_client = new_client p_transport = patch( "cleveragents.lsp.lifecycle.StdioTransport", return_value=new_transport, ) p_client = patch( "cleveragents.lsp.lifecycle.LspClient", return_value=new_client, ) p_transport.start() p_client.start() context.add_cleanup(p_transport.stop) context.add_cleanup(p_client.stop) context.llcov_client = context.llcov_manager.restart_server(name) @when('llcov I attempt to restart server "{name}"') def step_attempt_restart_server(context: Context, name: str) -> None: try: # For "not found" case, don't set up mocks if not context.llcov_manager._servers.get(name): context.llcov_manager.restart_server(name) else: # For init failure during restart context.llcov_old_transport = context.llcov_mock_transport new_transport = _make_mock_transport() new_client = _make_mock_client() new_client.initialize.side_effect = RuntimeError("restart init boom") context.llcov_new_restart_transport = new_transport p_transport = patch( "cleveragents.lsp.lifecycle.StdioTransport", return_value=new_transport, ) p_client = patch( "cleveragents.lsp.lifecycle.LspClient", return_value=new_client, ) p_transport.start() p_client.start() context.add_cleanup(p_transport.stop) context.add_cleanup(p_client.stop) context.llcov_client = context.llcov_manager.restart_server(name) except Exception as exc: context.llcov_error = exc @given("llcov the LspClient initialize will raise on restart") def step_init_fails_on_restart(context: Context) -> None: context.llcov_init_fails_on_restart = True # --------------------------------------------------------------------------- # Stop all # --------------------------------------------------------------------------- @when("llcov I stop all servers") def step_stop_all(context: Context) -> None: context.llcov_manager.stop_all() # --------------------------------------------------------------------------- # List running # --------------------------------------------------------------------------- @when("llcov I list running servers") def step_list_running(context: Context) -> None: context.llcov_running_list = context.llcov_manager.list_running() # --------------------------------------------------------------------------- # Then assertions # --------------------------------------------------------------------------- @then("llcov the returned client should not be None") def step_client_not_none(context: Context) -> None: assert context.llcov_client is not None, "Expected a client but got None" @then('llcov the server "{name}" should be registered') def step_server_registered(context: Context, name: str) -> None: assert name in context.llcov_manager._servers, ( f"Server '{name}' not found in _servers" ) @then('llcov the server "{name}" should not be registered') def step_server_not_registered(context: Context, name: str) -> None: assert name not in context.llcov_manager._servers, ( f"Server '{name}' should not be in _servers" ) @then('llcov the server "{name}" should still be registered') def step_server_still_registered(context: Context, name: str) -> None: assert name in context.llcov_manager._servers, ( f"Server '{name}' should still be in _servers" ) @then("llcov the returned client should be the same as the original") def step_client_same_as_original(context: Context) -> None: assert context.llcov_client is context.llcov_original_client, ( "Expected reused client to be the same object" ) @then('llcov the ref count for "{name}" should be {count:d}') def step_ref_count(context: Context, name: str, count: int) -> None: managed = context.llcov_manager._servers[name] assert managed.ref_count == count, ( f"Expected ref_count={count}, got {managed.ref_count}" ) @then("llcov a start error should have been raised") def step_start_error_raised(context: Context) -> None: assert context.llcov_error is not None, "Expected an error but none was raised" assert isinstance(context.llcov_error, RuntimeError), ( f"Expected RuntimeError, got {type(context.llcov_error)}" ) @then("llcov the transport should have been stopped after init failure") def step_transport_stopped_after_init(context: Context) -> None: context.llcov_mock_transport.stop.assert_called() @then("llcov the returned client should be the race winner client") def step_client_is_race_winner(context: Context) -> None: assert context.llcov_client is context.llcov_race_client, ( "Expected the race winner client" ) @then("llcov the losing transport should have been stopped") def step_losing_transport_stopped(context: Context) -> None: context.llcov_phase2_transport.stop.assert_called() @then("llcov shutdown should have been called on the client") def step_shutdown_called(context: Context) -> None: context.llcov_mock_client.shutdown.assert_called() @then("llcov a LspServerNotFoundError should have been raised") def step_not_found_error(context: Context) -> None: assert context.llcov_error is not None, "Expected an error but none was raised" assert isinstance(context.llcov_error, LspServerNotFoundError), ( f"Expected LspServerNotFoundError, got {type(context.llcov_error)}" ) @then("llcov the client shutdown method should have been called") def step_client_shutdown_called(context: Context) -> None: context.llcov_mock_client.shutdown.assert_called() @then("llcov the transport stop method should have been called") def step_transport_stop_called(context: Context) -> None: context.llcov_mock_transport.stop.assert_called() @then("llcov the transport stop method should have been called despite error") def step_transport_stop_called_despite_error(context: Context) -> None: context.llcov_mock_transport.stop.assert_called() @then("llcov the health check result should be true") def step_health_true(context: Context) -> None: assert context.llcov_health is True, f"Expected True, got {context.llcov_health}" @then("llcov the health check result should be false") def step_health_false(context: Context) -> None: assert context.llcov_health is False, f"Expected False, got {context.llcov_health}" @then("llcov the old transport should have been stopped") def step_old_transport_stopped(context: Context) -> None: context.llcov_old_transport.stop.assert_called() @then("llcov the managed server should have the new transport and client") def step_managed_has_new(context: Context) -> None: name = context.llcov_config.name managed = context.llcov_manager._servers[name] assert managed.transport is context.llcov_new_transport, ( "Managed server should have the new transport" ) assert managed.client is context.llcov_new_client, ( "Managed server should have the new client" ) @then("llcov a restart error should have been raised") def step_restart_error_raised(context: Context) -> None: assert context.llcov_error is not None, "Expected an error but none was raised" assert isinstance(context.llcov_error, RuntimeError), ( f"Expected RuntimeError, got {type(context.llcov_error)}" ) @then("llcov the new transport should have been stopped after restart failure") def step_new_transport_stopped_after_restart(context: Context) -> None: context.llcov_new_restart_transport.stop.assert_called() @then("llcov no servers should be registered") def step_no_servers(context: Context) -> None: assert len(context.llcov_manager._servers) == 0, ( f"Expected 0 servers, got {len(context.llcov_manager._servers)}" ) @then("llcov the running list should contain {count:d} entry") def step_running_list_count_singular(context: Context, count: int) -> None: assert len(context.llcov_running_list) == count, ( f"Expected {count} entries, got {len(context.llcov_running_list)}" ) @then("llcov the running list should contain {count:d} entries") def step_running_list_count_plural(context: Context, count: int) -> None: assert len(context.llcov_running_list) == count, ( f"Expected {count} entries, got {len(context.llcov_running_list)}" ) @then('llcov the running entry for "{name}" should have workspace "{workspace}"') def step_running_entry_workspace(context: Context, name: str, workspace: str) -> None: entry = next((e for e in context.llcov_running_list if e["name"] == name), None) assert entry is not None, f"No entry found for '{name}'" assert entry["workspace"] == workspace, ( f"Expected workspace='{workspace}', got '{entry['workspace']}'" ) @then('llcov the running entry for "{name}" should have alive status true') def step_running_entry_alive(context: Context, name: str) -> None: entry = next((e for e in context.llcov_running_list if e["name"] == name), None) assert entry is not None, f"No entry found for '{name}'" assert entry["alive"] is True, f"Expected alive=True, got {entry['alive']}" @then('llcov the running entry for "{name}" should have ref count {count:d}') def step_running_entry_ref_count(context: Context, name: str, count: int) -> None: entry = next((e for e in context.llcov_running_list if e["name"] == name), None) assert entry is not None, f"No entry found for '{name}'" assert entry["ref_count"] == count, ( f"Expected ref_count={count}, got {entry['ref_count']}" ) # --------------------------------------------------------------------------- # Concurrent-access tests for 3-phase lock pattern in restart_server() # --------------------------------------------------------------------------- @when( "llcov I restart the server concurrently and health_check runs during blocking IO" ) def step_restart_concurrent_health_check(context: Context) -> None: """Verify health_check() is not blocked while restart_server() is in progress. We inject a barrier into the mock ``initialize`` call so that the restart_server() thread pauses in Phase 2 (blocking I/O, no lock held). A second thread then calls health_check() and records whether it completed without blocking. """ # Barrier: both threads must reach it before either proceeds. # Thread 1 (restart): reaches barrier inside initialize() # Thread 2 (health_check): reaches barrier after acquiring the lock barrier = threading.Barrier(2, timeout=5) health_check_completed = threading.Event() health_check_result: list[bool] = [] new_transport = _make_mock_transport() new_client = _make_mock_client() def _blocking_initialize(workspace_path: str) -> None: """Simulate a slow LSP handshake; signal the barrier mid-way.""" barrier.wait() # Signal: "I'm in Phase 2, lock is released" new_client.initialize.side_effect = _blocking_initialize def _restart_thread() -> None: p_transport = patch( "cleveragents.lsp.lifecycle.StdioTransport", return_value=new_transport, ) p_client = patch( "cleveragents.lsp.lifecycle.LspClient", return_value=new_client, ) with p_transport, p_client: context.llcov_restarted_client = context.llcov_manager.restart_server( context.llcov_config.name ) def _health_check_thread() -> None: barrier.wait() # Wait until restart is in Phase 2 (lock released) # health_check() must acquire _lock; if restart held it, this would block result = context.llcov_manager.health_check(context.llcov_config.name) health_check_result.append(result) health_check_completed.set() t_restart = threading.Thread(target=_restart_thread, daemon=True) t_health = threading.Thread(target=_health_check_thread, daemon=True) t_restart.start() t_health.start() t_restart.join(timeout=10) t_health.join(timeout=10) context.llcov_health_check_completed = health_check_completed.is_set() context.llcov_health_check_result_concurrent = health_check_result context.llcov_new_transport = new_transport context.llcov_new_client = new_client @then("llcov health_check should have completed without blocking") def step_health_check_completed(context: Context) -> None: assert context.llcov_health_check_completed, ( "health_check() did not complete — it was blocked while restart_server() " "held the lock during blocking I/O" ) @then("llcov the restarted client should not be None") def step_restarted_client_not_none(context: Context) -> None: assert context.llcov_restarted_client is not None, ( "Expected a restarted client but got None" ) @when("llcov I restart the server and verify lock is free during initialize") def step_restart_verify_lock_free(context: Context) -> None: """Assert that _lock is NOT held when client.initialize() is called. We inspect the lock state from within the mock initialize() call. If the lock is held, ``_lock.acquire(blocking=False)`` returns False. """ lock_was_free_during_init: list[bool] = [] manager = context.llcov_manager new_transport = _make_mock_transport() new_client = _make_mock_client() def _check_lock_initialize(workspace_path: str) -> None: # Try to acquire the lock non-blocking; succeeds only if not held acquired = manager._lock.acquire(blocking=False) lock_was_free_during_init.append(acquired) if acquired: manager._lock.release() new_client.initialize.side_effect = _check_lock_initialize p_transport = patch( "cleveragents.lsp.lifecycle.StdioTransport", return_value=new_transport, ) p_client = patch( "cleveragents.lsp.lifecycle.LspClient", return_value=new_client, ) with p_transport, p_client: context.llcov_restarted_client = context.llcov_manager.restart_server( context.llcov_config.name ) context.llcov_lock_was_free_during_init = lock_was_free_during_init context.llcov_new_transport = new_transport context.llcov_new_client = new_client @then("llcov the lock should have been acquirable during initialize") def step_lock_free_during_init(context: Context) -> None: results = context.llcov_lock_was_free_during_init assert results, "initialize() was never called — cannot verify lock state" assert all(results), ( "The lock was held during client.initialize() — restart_server() " "must not hold _lock during blocking I/O" )