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
773 lines
28 KiB
Python
773 lines
28 KiB
Python
"""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"
|
|
)
|