diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3b3bda..1ce9cb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: fail-fast: false matrix: python-version: ["3.13"] - os: [ubuntu-latest] + os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v6 diff --git a/pyproject.toml b/pyproject.toml index 1d0d10c..8f3124a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", ] dependencies = [ "anyio>=4.12.0", diff --git a/src/lsp_cli/cli/shared.py b/src/lsp_cli/cli/shared.py index eac3a00..f982e25 100644 --- a/src/lsp_cli/cli/shared.py +++ b/src/lsp_cli/cli/shared.py @@ -11,7 +11,7 @@ from lsp_cli.manager import CreateClientRequest, CreateClientResponse from lsp_cli.server import get_manager_client from lsp_cli.utils.http import AsyncHttpClient -from lsp_cli.utils.socket import wait_socket +from lsp_cli.utils.socket import wait_for_server def clean_error_msg(msg: str) -> str: @@ -32,12 +32,18 @@ async def managed_client(path: Path) -> AsyncGenerator[AsyncHttpClient]: ) assert info is not None - uds_path = info.uds_path - await wait_socket(uds_path, timeout=10.0) + conn = info.conn + await wait_for_server( + uds_path=conn.uds_path, host=conn.host, port=conn.port, timeout=10.0 + ) + + if conn.uds_path: + transport = httpx.AsyncHTTPTransport(uds=conn.uds_path.as_posix()) + else: + transport = httpx.AsyncHTTPTransport() - transport = httpx.AsyncHTTPTransport(uds=uds_path.as_posix()) async with AsyncHttpClient( - httpx.AsyncClient(transport=transport, base_url="http://localhost") + httpx.AsyncClient(transport=transport, base_url=conn.url) ) as client: yield client diff --git a/src/lsp_cli/manager/__init__.py b/src/lsp_cli/manager/__init__.py index df49b79..da418be 100644 --- a/src/lsp_cli/manager/__init__.py +++ b/src/lsp_cli/manager/__init__.py @@ -5,12 +5,13 @@ import httpx -from lsp_cli.settings import MANAGER_UDS_PATH +from lsp_cli.settings import MANAGER_CONN_PATH from lsp_cli.utils.http import HttpClient -from lsp_cli.utils.socket import is_socket_alive +from lsp_cli.utils.socket import is_server_alive from .manager import Manager, get_manager, manager_lifespan from .models import ( + ConnectionInfo, CreateClientRequest, CreateClientResponse, DeleteClientRequest, @@ -34,7 +35,18 @@ def connect_manager() -> HttpClient: - if not is_socket_alive(MANAGER_UDS_PATH): + conn = None + if MANAGER_CONN_PATH.exists(): + try: + conn = ConnectionInfo.model_validate_json(MANAGER_CONN_PATH.read_text()) + except (OSError, ValueError, Exception): + # Failed to read or parse connection info - will try to start manager + # Catches OSError (file read), ValueError (JSON/validation), or other parsing errors + pass + + if not conn or not is_server_alive( + uds_path=conn.uds_path, host=conn.host, port=conn.port + ): subprocess.Popen( (sys.executable, "-m", "lsp_cli.manager"), stdin=subprocess.DEVNULL, @@ -42,10 +54,36 @@ def connect_manager() -> HttpClient: stderr=subprocess.DEVNULL, start_new_session=True, ) + # Wait for manager.json to be created and server to be alive + import time + + start = time.time() + while time.time() - start < 10: + if MANAGER_CONN_PATH.exists(): + try: + conn = ConnectionInfo.model_validate_json( + MANAGER_CONN_PATH.read_text() + ) + if is_server_alive( + uds_path=conn.uds_path, host=conn.host, port=conn.port + ): + break + except (OSError, ValueError, Exception): + # Failed to read/parse - retry in next iteration + pass + time.sleep(0.1) + else: + raise RuntimeError("Failed to start manager") + + assert conn is not None + if conn.uds_path: + transport = httpx.HTTPTransport(uds=str(conn.uds_path), retries=5) + else: + transport = httpx.HTTPTransport(retries=5) return HttpClient( httpx.Client( - transport=httpx.HTTPTransport(uds=str(MANAGER_UDS_PATH), retries=5), - base_url="http://localhost", + transport=transport, + base_url=conn.url, ) ) diff --git a/src/lsp_cli/manager/__main__.py b/src/lsp_cli/manager/__main__.py index 6a5dfcc..86b2c15 100644 --- a/src/lsp_cli/manager/__main__.py +++ b/src/lsp_cli/manager/__main__.py @@ -1,10 +1,31 @@ import uvicorn -from lsp_cli.settings import MANAGER_UDS_PATH +from lsp_cli.manager.models import ConnectionInfo +from lsp_cli.settings import IS_WINDOWS, MANAGER_CONN_PATH, MANAGER_UDS_PATH +from lsp_cli.utils.socket import allocate_port from .manager import app if __name__ == "__main__": - MANAGER_UDS_PATH.unlink(missing_ok=True) - MANAGER_UDS_PATH.parent.mkdir(parents=True, exist_ok=True) - uvicorn.run(app, uds=str(MANAGER_UDS_PATH)) + if IS_WINDOWS: + sock, port = allocate_port() + try: + conn = ConnectionInfo(host="127.0.0.1", port=port) + MANAGER_CONN_PATH.parent.mkdir(parents=True, exist_ok=True) + MANAGER_CONN_PATH.write_text(conn.model_dump_json()) + # On Windows, fd is not supported by uvicorn, so we close the socket + # before starting the server. + sock.close() + uvicorn.run(app, host="127.0.0.1", port=port) + finally: + # ensure socket is closed if it wasn't already + try: + sock.close() + except Exception: + pass + else: + MANAGER_UDS_PATH.unlink(missing_ok=True) + MANAGER_UDS_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = ConnectionInfo(uds_path=MANAGER_UDS_PATH) + MANAGER_CONN_PATH.write_text(conn.model_dump_json()) + uvicorn.run(app, uds=str(MANAGER_UDS_PATH)) diff --git a/src/lsp_cli/manager/client.py b/src/lsp_cli/manager/client.py index d32b15a..3d8ec93 100644 --- a/src/lsp_cli/manager/client.py +++ b/src/lsp_cli/manager/client.py @@ -15,9 +15,10 @@ from lsp_cli.client import TargetClient from lsp_cli.manager.capability import CapabilityController, Capabilities -from lsp_cli.settings import LOG_DIR, RUNTIME_DIR, settings +from lsp_cli.settings import IS_WINDOWS, LOG_DIR, RUNTIME_DIR, settings +from lsp_cli.utils.socket import allocate_port -from .models import ManagedClientInfo +from .models import ConnectionInfo, ManagedClientInfo def get_client_id(target: TargetClient) -> str: @@ -39,6 +40,8 @@ class ManagedClient: _logger: loguru.Logger = field(init=False) _logger_sink_id: int = field(init=False) + _port: int | None = field(init=False, default=None) + _ready_event: anyio.Event = field(init=False, factory=anyio.Event) def __attrs_post_init__(self) -> None: self._deadline = anyio.current_time() + settings.idle_timeout @@ -63,6 +66,21 @@ def __attrs_post_init__(self) -> None: def id(self) -> str: return get_client_id(self.target) + async def wait_ready(self) -> None: + """Wait until the client is assigned a port/socket and ready to serve.""" + await self._ready_event.wait() + + @property + def conn(self) -> ConnectionInfo: + if IS_WINDOWS: + if self._port is None: + raise RuntimeError( + "Connection information is not available yet: " + "the managed client has not been assigned a port." + ) + return ConnectionInfo(host="127.0.0.1", port=self._port) + return ConnectionInfo(uds_path=self.uds_path) + @property def uds_path(self) -> Path: return RUNTIME_DIR / f"{self.id}.sock" @@ -126,12 +144,25 @@ def exception_handler(request: Request, exc: Exception) -> Response: exception_handlers={Exception: exception_handler}, ) - config = uvicorn.Config( - app, - uds=str(self.uds_path), - loop="asyncio", - log_config=None, # Disable default uvicorn logging - ) + if IS_WINDOWS: + # Port should already be allocated in run() + port = self._port + if port is None: + raise RuntimeError("Port not allocated") + config = uvicorn.Config( + app, + host="127.0.0.1", + port=port, + loop="asyncio", + log_config=None, + ) + else: + config = uvicorn.Config( + app, + uds=str(self.uds_path), + loop="asyncio", + log_config=None, # Disable default uvicorn logging + ) self._server = uvicorn.Server(config) async with asyncer.create_task_group() as tg: @@ -141,21 +172,32 @@ def exception_handler(request: Request, exc: Exception) -> Response: await self._server.serve() async def run(self) -> None: + if IS_WINDOWS: + sock, port_val = allocate_port() + self._port = port_val + # On Windows, fd is not supported by uvicorn, so we close the socket + # before starting the server. + sock.close() + else: + uds_path = anyio.Path(self.uds_path) + await uds_path.unlink(missing_ok=True) + await uds_path.parent.mkdir(parents=True, exist_ok=True) + + # Signal that connection info is available (port/socket path assigned) + self._ready_event.set() + self._logger.info( "Starting managed client for project {} at {}", self.target.project_path, - self.uds_path, + self.conn, ) - uds_path = anyio.Path(self.uds_path) - await uds_path.unlink(missing_ok=True) - await uds_path.parent.mkdir(parents=True, exist_ok=True) - try: await self._serve() finally: self._logger.info("Cleaning up client") - await uds_path.unlink(missing_ok=True) + if not IS_WINDOWS: + await anyio.Path(self.uds_path).unlink(missing_ok=True) self._logger.remove(self._logger_sink_id) self._timeout_scope.cancel() self._server_scope.cancel() diff --git a/src/lsp_cli/manager/manager.py b/src/lsp_cli/manager/manager.py index 7711a92..026c3dd 100644 --- a/src/lsp_cli/manager/manager.py +++ b/src/lsp_cli/manager/manager.py @@ -19,6 +19,7 @@ from .client import ManagedClient, get_client_id from .models import ( + ConnectionInfo, CreateClientRequest, CreateClientResponse, DeleteClientRequest, @@ -49,7 +50,7 @@ def __attrs_post_init__(self) -> None: f"[Manager] Manager log initialized at {log_path} (level: {log_level})" ) - async def create_client(self, path: Path) -> Path: + async def create_client(self, path: Path) -> ConnectionInfo: target = find_client(path) if not target: raise NotFoundException(f"No LSP client found for path: {path}") @@ -62,11 +63,13 @@ async def create_client(self, path: Path) -> Path: m_client = ManagedClient(target) self._clients[client_id] = m_client self._tg.soonify(self._run_client)(m_client) + # Wait for the client to be ready (assigned a port/socket) + await m_client.wait_ready() else: logger.info(f"[Manager] Reusing existing client: {client_id}") self._clients[client_id]._reset_timeout() - return self._clients[client_id].uds_path + return self._clients[client_id].conn @logger.catch(level="ERROR") async def _run_client(self, client: ManagedClient) -> None: @@ -131,12 +134,12 @@ async def create_client_handler( data: CreateClientRequest, state: State ) -> CreateClientResponse: manager = get_manager(state) - uds_path = await manager.create_client(data.path) + conn = await manager.create_client(data.path) info = manager.inspect_client(data.path) if not info: raise RuntimeError("Failed to create client") - return CreateClientResponse(uds_path=uds_path, info=info) + return CreateClientResponse(conn=conn, info=info) @delete("/delete", status_code=200) diff --git a/src/lsp_cli/manager/models.py b/src/lsp_cli/manager/models.py index a22127e..b6e08b6 100644 --- a/src/lsp_cli/manager/models.py +++ b/src/lsp_cli/manager/models.py @@ -6,6 +6,37 @@ from pydantic import BaseModel, RootModel +class ConnectionInfo(BaseModel): + """Connection information for LSP server communication. + + On Unix-like systems, uses Unix Domain Sockets (uds_path). + On Windows, uses TCP with host and port. + + Security Note: When using TCP (Windows), the server binds to 127.0.0.1 + (localhost only) without authentication. This means any local process + can connect to the manager. This is acceptable for a local development + tool, but should be considered when handling sensitive data. + """ + + uds_path: Path | None = None + host: str | None = None + port: int | None = None + + @property + def url(self) -> str: + """ + Return the HTTP URL for this connection. + + If both ``host`` and ``port`` are set, this returns + ``"http://{host}:{port}"``. If either value is missing, this + falls back to ``"http://localhost"`` (used primarily for UDS connections + where the actual network address is not applicable). + """ + if self.host and self.port: + return f"http://{self.host}:{self.port}" + return "http://localhost" + + class ManagedClientInfo(BaseModel): project_path: Path language: str @@ -31,7 +62,7 @@ class CreateClientRequest(BaseModel): class CreateClientResponse(BaseModel): - uds_path: Path + conn: ConnectionInfo info: ManagedClientInfo diff --git a/src/lsp_cli/manager/server.py b/src/lsp_cli/manager/server.py index 9cd6e9a..138ebf3 100644 --- a/src/lsp_cli/manager/server.py +++ b/src/lsp_cli/manager/server.py @@ -2,12 +2,10 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from functools import cached_property -from pathlib import Path from typing import Self, final, override import httpx -from attrs import define +from attrs import define, field from lsp_client.jsonrpc.types import ( RawNotification, RawRequest, @@ -18,32 +16,49 @@ from lsp_client.utils.channel import Sender from lsp_client.utils.workspace import Workspace -from lsp_cli.utils.socket import wait_socket +from lsp_cli.manager.models import ConnectionInfo +from lsp_cli.utils.socket import wait_for_server @final @define class ManagerServer(Server): - uds_path: Path + conn: ConnectionInfo + _client: httpx.AsyncClient | None = field(init=False, default=None) - @cached_property + @property def client(self) -> httpx.AsyncClient: - transport = httpx.AsyncHTTPTransport(uds=self.uds_path.as_posix()) - return httpx.AsyncClient( - transport=transport, - base_url="http://localhost", - timeout=None, - ) + """Get or create the HTTP client for this server.""" + if self._client is None: + if self.conn.uds_path: + transport = httpx.AsyncHTTPTransport(uds=self.conn.uds_path.as_posix()) + else: + transport = httpx.AsyncHTTPTransport() + + self._client = httpx.AsyncClient( + transport=transport, + base_url=self.conn.url, + timeout=None, + ) + return self._client + + async def _close_client(self) -> None: + """Close the HTTP client if it exists.""" + if self._client is not None: + await self._client.aclose() + self._client = None @override async def check_availability(self) -> None: - if not self.uds_path.exists(): - raise ServerRuntimeError(self, f"Server socket not found: {self.uds_path}") + if self.conn.uds_path and not self.conn.uds_path.exists(): + raise ServerRuntimeError( + self, f"Server socket not found: {self.conn.uds_path}" + ) try: await self.client.get("/health") except httpx.HTTPError as e: raise ServerRuntimeError( - self, f"Managed server at {self.uds_path} is not responding: {e}" + self, f"Managed server at {self.conn.url} is not responding: {e}" ) from e @override @@ -69,5 +84,13 @@ async def wait_requests_completed(self, timeout: float | None = None) -> None: async def run( self, workspace: Workspace, sender: Sender[ServerRequest] ) -> AsyncGenerator[Self]: - await wait_socket(self.uds_path, timeout=10.0) - yield self + await wait_for_server( + uds_path=self.conn.uds_path, + host=self.conn.host, + port=self.conn.port, + timeout=10.0, + ) + try: + yield self + finally: + await self._close_client() diff --git a/src/lsp_cli/settings.py b/src/lsp_cli/settings.py index b6befcf..92553fc 100644 --- a/src/lsp_cli/settings.py +++ b/src/lsp_cli/settings.py @@ -1,3 +1,4 @@ +import platform from pathlib import Path from typing import Final, Literal @@ -13,7 +14,9 @@ CONFIG_PATH = Path(user_config_dir(APP_NAME)) / "config.toml" RUNTIME_DIR = Path(user_runtime_dir(APP_NAME)) LOG_DIR = Path(user_log_dir(APP_NAME)) +IS_WINDOWS = platform.system() == "Windows" MANAGER_UDS_PATH = RUNTIME_DIR / "manager.sock" +MANAGER_CONN_PATH = RUNTIME_DIR / "manager.json" LogLevel = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] diff --git a/src/lsp_cli/utils/socket.py b/src/lsp_cli/utils/socket.py index cd9e2da..4aecd31 100644 --- a/src/lsp_cli/utils/socket.py +++ b/src/lsp_cli/utils/socket.py @@ -5,23 +5,99 @@ from tenacity import AsyncRetrying, stop_after_delay, wait_fixed -def is_socket_alive(path: Path) -> bool: - try: - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: - s.connect(str(path)) - return True - except OSError: - return False +def allocate_port() -> tuple[socket.socket, int]: + """Allocate a free TCP port by binding to port 0. + Returns a tuple of (socket, port). The socket is kept open and should be + passed to uvicorn via the `fd` parameter to avoid a race condition where + another process could bind to the same port between closing the socket + and uvicorn starting. + + Note: There is still a small race condition if the socket is closed before + uvicorn binds to it. The recommended pattern is to pass the socket's file + descriptor directly to uvicorn using `fd=socket.fileno()`, but this may not + work on all platforms. For best reliability, keep the socket open until + uvicorn has bound to the port. + + Returns: + tuple[socket.socket, int]: A tuple of (socket object, port number). + The caller is responsible for closing the socket after uvicorn starts. + """ + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("127.0.0.1", 0)) + s.listen() + port = s.getsockname()[1] + assert isinstance(port, int) + return s, port + + +def is_server_alive( + uds_path: Path | None = None, host: str | None = None, port: int | None = None +) -> bool: + if uds_path and uds_path.exists(): + try: + af_unix = getattr(socket, "AF_UNIX", None) + if af_unix is not None: + with socket.socket(af_unix, socket.SOCK_STREAM) as s: + s.connect(str(uds_path)) + return True + except OSError: + # Connection failed - socket file exists but server is not responding + pass + + if host and port: + try: + with socket.create_connection((host, port), timeout=1.0): + return True + except OSError: + # Connection failed - server is not listening on this port + pass + + return False + + +async def wait_for_server( + uds_path: Path | None = None, + host: str | None = None, + port: int | None = None, + timeout: float = 10.0, +) -> None: + """Wait for a server to become available. + + Raises: + ValueError: If only uds_path is provided on Windows where UDS is not available. + OSError: If the server does not become ready within the timeout period. + """ + # Check if we have any valid connection method + af_unix = getattr(socket, "AF_UNIX", None) + has_uds_support = af_unix is not None + has_tcp_info = host and port + + # Fail fast if only UDS is provided but not supported + if uds_path and not has_uds_support and not has_tcp_info: + raise ValueError( + "Unix Domain Sockets are not available on this platform, " + "but only uds_path was provided without TCP fallback (host/port)" + ) -async def wait_socket(path: Path, timeout: float = 10.0) -> None: async for attempt in AsyncRetrying( stop=stop_after_delay(timeout), wait=wait_fixed(0.1), reraise=True, ): with attempt: - try: - _ = await anyio.connect_unix(path) - except (OSError, RuntimeError): - raise OSError(f"Socket {path} not ready") + if uds_path and has_uds_support: + try: + _ = await anyio.connect_unix(uds_path) + return + except (OSError, RuntimeError): + # Connection failed; suppress to try TCP or retry + pass + if host and port: + try: + _ = await anyio.connect_tcp(host, port) + return + except (OSError, RuntimeError): + # Connection failed; suppress to retry + pass + raise OSError("Server not ready") diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py index 2ca3e07..a673269 100644 --- a/tests/test_cli_integration.py +++ b/tests/test_cli_integration.py @@ -179,11 +179,45 @@ def test_no_connection_errors(self, test_project_file): def test_manager_auto_start_reliability(self): """Test that manager auto-starts reliably.""" # Kill any existing manager - subprocess.run( - ["pkill", "-f", "lsp_cli.manager"], - capture_output=True, - ) - time.sleep(0.5) + import os + + if os.name == "nt": + # On Windows, try to use WMIC if available, otherwise skip this test step + # WMIC is deprecated in newer Windows versions but may still be present + try: + result = subprocess.run( + [ + "wmic", + "process", + "where", + "CommandLine like '%lsp_cli.manager%'", + "delete", + ], + capture_output=True, + timeout=5, + ) + # If WMIC is not available, try tasklist/taskkill as fallback + if result.returncode != 0: + # Try to use PowerShell as a more modern alternative + subprocess.run( + [ + "powershell", + "-Command", + "Get-Process | Where-Object {$_.CommandLine -like '*lsp_cli.manager*'} | Stop-Process -Force", + ], + capture_output=True, + timeout=5, + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + # WMIC/PowerShell not available or timed out - continue with test + # The manager auto-start will still work if no manager is running + pass + else: + subprocess.run( + ["pkill", "-f", "lsp_cli.manager"], + capture_output=True, + ) + time.sleep(1.0) # First command should auto-start manager result = self.run_lsp_command("server", "list") diff --git a/tests/test_language_support.py b/tests/test_language_support.py index 85271b3..2f549f8 100644 --- a/tests/test_language_support.py +++ b/tests/test_language_support.py @@ -15,6 +15,7 @@ 3. Stop the server cleanly """ +import time from pathlib import Path import pytest @@ -44,9 +45,19 @@ def test_python_support(self, fixtures_dir): ) # List servers - should show Python server + # Give the server a moment to register in the manager + time.sleep(1.0) result = self.run_lsp_command("server", "list") assert result.returncode == 0, f"Failed to list servers: {result.stderr}" - assert "python" in result.stdout.lower(), "Python server not listed" + + # If standard list doesn't show it, try one more time with a longer wait + if "python" not in result.stdout.lower(): + time.sleep(2.0) + result = self.run_lsp_command("server", "list") + + assert "python" in result.stdout.lower(), ( + f"Python server not listed. Output: {result.stdout}" + ) finally: # Stop server result = self.run_lsp_command("server", "stop", str(python_file)) diff --git a/tests/test_server_management.py b/tests/test_server_management.py index 2488c7c..1cdd551 100644 --- a/tests/test_server_management.py +++ b/tests/test_server_management.py @@ -15,6 +15,7 @@ import pytest from lsp_cli.manager import ( + ConnectionInfo, CreateClientRequest, CreateClientResponse, DeleteClientRequest, @@ -22,30 +23,45 @@ ManagedClientInfoList, connect_manager, ) -from lsp_cli.settings import MANAGER_UDS_PATH, RUNTIME_DIR +from lsp_cli.settings import ( + IS_WINDOWS, + MANAGER_CONN_PATH, + MANAGER_UDS_PATH, +) from lsp_cli.utils.http import AsyncHttpClient, HttpClient -from lsp_cli.utils.socket import is_socket_alive, wait_socket +from lsp_cli.utils.socket import is_server_alive, wait_for_server @pytest.fixture(scope="module") def manager_process(): """Start the manager process for testing.""" - MANAGER_UDS_PATH.unlink(missing_ok=True) + if MANAGER_CONN_PATH.exists(): + MANAGER_CONN_PATH.unlink() + if MANAGER_UDS_PATH.exists(): + MANAGER_UDS_PATH.unlink() MANAGER_UDS_PATH.parent.mkdir(parents=True, exist_ok=True) proc = subprocess.Popen( [sys.executable, "-m", "lsp_cli.manager"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, ) # Wait for manager to be ready timeout = 10 start = time.time() + conn = None while time.time() - start < timeout: - if is_socket_alive(MANAGER_UDS_PATH): - break + if MANAGER_CONN_PATH.exists(): + try: + conn = ConnectionInfo.model_validate_json(MANAGER_CONN_PATH.read_text()) + if is_server_alive( + uds_path=conn.uds_path, host=conn.host, port=conn.port + ): + break + except (OSError, ValueError, Exception): + # Failed to read/parse connection info - retry + pass time.sleep(0.1) else: proc.kill() @@ -61,7 +77,10 @@ def manager_process(): proc.kill() proc.wait() - MANAGER_UDS_PATH.unlink(missing_ok=True) + if MANAGER_CONN_PATH.exists(): + MANAGER_CONN_PATH.unlink() + if MANAGER_UDS_PATH.exists(): + MANAGER_UDS_PATH.unlink() @pytest.fixture @@ -73,13 +92,43 @@ def test_file(): return file +def _load_connection_info() -> ConnectionInfo: + """Load and validate the manager connection info from disk with error handling.""" + try: + data = MANAGER_CONN_PATH.read_text() + except OSError as exc: + raise RuntimeError( + f"Manager connection file is not available at {MANAGER_CONN_PATH!s}" + ) from exc + + try: + return ConnectionInfo.model_validate_json(data) + except Exception as exc: + raise RuntimeError( + f"Invalid manager connection data in {MANAGER_CONN_PATH!s}" + ) from exc + + +def get_manager_transport(): + conn = _load_connection_info() + if conn.uds_path: + return httpx.AsyncHTTPTransport(uds=str(conn.uds_path), retries=5) + return httpx.AsyncHTTPTransport(retries=5) + + +def get_manager_url(): + conn = _load_connection_info() + return conn.url + + class TestManagerConnection: """Test manager connection reliability.""" - def test_manager_socket_exists(self, manager_process): - """Test that manager socket file is created.""" - assert MANAGER_UDS_PATH.exists() - assert is_socket_alive(MANAGER_UDS_PATH) + def test_manager_conn_exists(self, manager_process): + """Test that manager connection file is created.""" + assert MANAGER_CONN_PATH.exists() + conn = ConnectionInfo.model_validate_json(MANAGER_CONN_PATH.read_text()) + assert is_server_alive(uds_path=conn.uds_path, host=conn.host, port=conn.port) def test_connect_manager_creates_client(self, manager_process): """Test that connect_manager() creates a working HTTP client.""" @@ -120,11 +169,19 @@ async def test_create_client(self, manager_process, test_file): ) assert resp is not None - # Wait for socket to be created - await wait_socket(resp.uds_path, timeout=10.0) + # Wait for server to be created + await wait_for_server( + uds_path=resp.conn.uds_path, + host=resp.conn.host, + port=resp.conn.port, + timeout=10.0, + ) - assert resp.uds_path.exists() - assert is_socket_alive(resp.uds_path) + if resp.conn.uds_path: + assert resp.conn.uds_path.exists() + assert is_server_alive( + uds_path=resp.conn.uds_path, host=resp.conn.host, port=resp.conn.port + ) # The project path is determined by find_client logic # It should be a parent directory of test_file assert test_file.is_relative_to(resp.info.project_path) @@ -141,7 +198,7 @@ def test_client_reuse(self, manager_process, test_file): json=CreateClientRequest(path=test_file), ) assert resp1 is not None - uds_path1 = resp1.uds_path + conn1 = resp1.conn time1 = resp1.info.remaining_time # Wait a bit to ensure time difference is measurable @@ -154,7 +211,7 @@ def test_client_reuse(self, manager_process, test_file): json=CreateClientRequest(path=test_file), ) assert resp2 is not None - assert resp2.uds_path == uds_path1 + assert resp2.conn == conn1 # Remaining time should be reset (approximately equal to full timeout) # Both should be close to the full idle_timeout @@ -170,7 +227,7 @@ def test_delete_client(self, manager_process, test_file): json=CreateClientRequest(path=test_file), ) assert create_resp is not None - uds_path = create_resp.uds_path + conn = create_resp.conn # Delete client delete_resp = client.delete( @@ -180,9 +237,11 @@ def test_delete_client(self, manager_process, test_file): ) assert delete_resp is not None - # Socket should be cleaned up shortly + # Server should be cleaned up shortly time.sleep(0.5) - assert not is_socket_alive(uds_path) + assert not is_server_alive( + uds_path=conn.uds_path, host=conn.host, port=conn.port + ) def test_list_clients(self, manager_process, test_file): """Test listing all clients.""" @@ -230,9 +289,9 @@ async def test_concurrent_client_creation(self, manager_process, test_file): files = [f for f in files if f.exists()][:3] # Limit to 3 files async def create_client(file: Path): - transport = httpx.AsyncHTTPTransport(uds=str(MANAGER_UDS_PATH), retries=5) + transport = get_manager_transport() async with httpx.AsyncClient( - transport=transport, base_url="http://localhost" + transport=transport, base_url=get_manager_url() ) as http_client: async with AsyncHttpClient(http_client) as client: try: @@ -242,9 +301,15 @@ async def create_client(file: Path): json=CreateClientRequest(path=file), ) if resp: - # Wait for socket to be created - await wait_socket(resp.uds_path, timeout=10.0) - assert resp.uds_path.exists() + # Wait for server to be created + await wait_for_server( + uds_path=resp.conn.uds_path, + host=resp.conn.host, + port=resp.conn.port, + timeout=10.0, + ) + if resp.conn.uds_path: + assert resp.conn.uds_path.exists() return resp except (httpx.HTTPStatusError, OSError): # Some files might not have LSP support or socket may not be ready @@ -260,9 +325,9 @@ async def test_concurrent_list_operations(self, manager_process): """Test concurrent list operations.""" async def list_clients(): - transport = httpx.AsyncHTTPTransport(uds=str(MANAGER_UDS_PATH), retries=5) + transport = get_manager_transport() async with httpx.AsyncClient( - transport=transport, base_url="http://localhost" + transport=transport, base_url=get_manager_url() ) as http_client: async with AsyncHttpClient(http_client) as client: resp = await client.get("/list", ManagedClientInfoList) @@ -286,9 +351,9 @@ async def test_mixed_concurrent_operations(self, manager_process, test_file): files = [f for f in files if f.exists()][:2] # Limit to 2 files async def create_client(file: Path): - transport = httpx.AsyncHTTPTransport(uds=str(MANAGER_UDS_PATH), retries=5) + transport = get_manager_transport() async with httpx.AsyncClient( - transport=transport, base_url="http://localhost" + transport=transport, base_url=get_manager_url() ) as http_client: async with AsyncHttpClient(http_client) as client: try: @@ -301,9 +366,9 @@ async def create_client(file: Path): pass async def list_clients(): - transport = httpx.AsyncHTTPTransport(uds=str(MANAGER_UDS_PATH), retries=5) + transport = get_manager_transport() async with httpx.AsyncClient( - transport=transport, base_url="http://localhost" + transport=transport, base_url=get_manager_url() ) as http_client: async with AsyncHttpClient(http_client) as client: return await client.get("/list", ManagedClientInfoList) @@ -316,46 +381,51 @@ async def list_clients(): tg.start_soon(list_clients) -class TestSocketWaiting: - """Test socket waiting functionality.""" +class TestServerWaiting: + """Test server waiting functionality.""" @pytest.mark.asyncio - async def test_wait_socket_success(self, manager_process): - """Test waiting for an existing socket.""" - await wait_socket(MANAGER_UDS_PATH, timeout=5.0) + async def test_wait_server_success(self, manager_process): + """Test waiting for an existing server.""" + conn = ConnectionInfo.model_validate_json(MANAGER_CONN_PATH.read_text()) + await wait_for_server( + uds_path=conn.uds_path, host=conn.host, port=conn.port, timeout=5.0 + ) @pytest.mark.asyncio - async def test_wait_socket_timeout(self): - """Test waiting for a non-existent socket times out.""" - non_existent = RUNTIME_DIR / "non_existent.sock" + async def test_wait_server_timeout(self): + """Test waiting for a non-existent server times out.""" with pytest.raises(OSError): - await wait_socket(non_existent, timeout=0.5) + await wait_for_server(host="127.0.0.1", port=65535, timeout=0.5) @pytest.mark.asyncio - async def test_wait_socket_becomes_available(self, tmp_path): - """Test waiting for a socket that becomes available.""" + async def test_wait_server_becomes_available(self, tmp_path): + """Test waiting for a server that becomes available.""" + # On windows we'd need to start a TCP server, but for simplicity + # we test with a file on Unix or just skip complex delayed start + if IS_WINDOWS: + pytest.skip("Delayed TCP start test not implemented for Windows") + sock_path = tmp_path / "delayed.sock" async def create_socket_delayed(): await anyio.sleep(0.5) - # Create a dummy socket file + # Create a dummy socket file - wait_for_server will try connect_unix + # which fails on a regular file, but we test the retry loop sock_path.touch() async with anyio.create_task_group() as tg: tg.start_soon(create_socket_delayed) - # This should wait and succeed once the file is created - # Note: This will still fail because we're just creating a file, - # not a real socket. The test shows the retry mechanism works. with pytest.raises(OSError): - await wait_socket(sock_path, timeout=2.0) + await wait_for_server(uds_path=sock_path, timeout=2.0) -class TestClientSocket: - """Test client socket functionality.""" +class TestClientServer: + """Test client server functionality.""" @pytest.mark.asyncio - async def test_client_socket_communication(self, manager_process, test_file): - """Test that we can communicate with a client through its socket.""" + async def test_client_server_communication(self, manager_process, test_file): + """Test that we can communicate with a client through its server.""" # Create client with connect_manager() as mgr_client: resp = mgr_client.post( @@ -364,15 +434,21 @@ async def test_client_socket_communication(self, manager_process, test_file): json=CreateClientRequest(path=test_file), ) assert resp is not None - uds_path = resp.uds_path + conn = resp.conn + + # Wait for server to be ready + await wait_for_server( + uds_path=conn.uds_path, host=conn.host, port=conn.port, timeout=10.0 + ) - # Wait for socket to be ready - await wait_socket(uds_path, timeout=10.0) + # Connect to the client server + if conn.uds_path: + transport = httpx.AsyncHTTPTransport(uds=conn.uds_path.as_posix()) + else: + transport = httpx.AsyncHTTPTransport() - # Connect to the client socket - transport = httpx.AsyncHTTPTransport(uds=uds_path.as_posix()) async with httpx.AsyncClient( - transport=transport, base_url="http://localhost", timeout=30.0 + transport=transport, base_url=conn.url, timeout=30.0 ) as http_client: # Test health endpoint (if exists) try: @@ -390,13 +466,13 @@ class TestAutoStartManager: def test_connect_manager_auto_starts(self): """Test that connect_manager auto-starts the manager if not running.""" # Ensure manager is not running + if MANAGER_CONN_PATH.exists(): + MANAGER_CONN_PATH.unlink() if MANAGER_UDS_PATH.exists(): MANAGER_UDS_PATH.unlink() # This should auto-start the manager with connect_manager() as client: - # Give it time to start - time.sleep(2) # Should be able to list clients resp = client.get("/list", ManagedClientInfoList) assert resp is not None @@ -441,9 +517,9 @@ async def test_high_concurrent_load(self, manager_process, test_file): """Test handling many concurrent requests.""" async def create_and_list(): - transport = httpx.AsyncHTTPTransport(uds=str(MANAGER_UDS_PATH), retries=5) + transport = get_manager_transport() async with httpx.AsyncClient( - transport=transport, base_url="http://localhost", timeout=30.0 + transport=transport, base_url=get_manager_url(), timeout=30.0 ) as http_client: async with AsyncHttpClient(http_client) as client: try: @@ -473,9 +549,9 @@ async def test_rapid_create_delete_cycle(self, manager_process, test_file): """Test rapid create/delete cycles don't cause issues.""" async def cycle(): - transport = httpx.AsyncHTTPTransport(uds=str(MANAGER_UDS_PATH), retries=5) + transport = get_manager_transport() async with httpx.AsyncClient( - transport=transport, base_url="http://localhost", timeout=30.0 + transport=transport, base_url=get_manager_url(), timeout=30.0 ) as http_client: async with AsyncHttpClient(http_client) as client: try: @@ -518,9 +594,9 @@ async def test_rapid_command_execution(self, manager_process, test_file): # Test by making rapid requests to the manager async def make_request(): - transport = httpx.AsyncHTTPTransport(uds=str(MANAGER_UDS_PATH), retries=5) + transport = get_manager_transport() async with httpx.AsyncClient( - transport=transport, base_url="http://localhost", timeout=30.0 + transport=transport, base_url=get_manager_url(), timeout=30.0 ) as http_client: try: async with AsyncHttpClient(http_client) as client: @@ -578,7 +654,7 @@ def test_cli_command_sequence(self, manager_process, test_file): json=CreateClientRequest(path=test_file), ) assert create_resp2 is not None - assert create_resp2.uds_path == create_resp.uds_path + assert create_resp2.conn == create_resp.conn # 5. Stop the server with connect_manager() as client: