Skip to content
This repository was archived by the owner on Jan 13, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/lsp_cli/manager/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ def connect_manager() -> HttpClient:
if MANAGER_CONN_PATH.exists():
try:
conn = ConnectionInfo.model_validate_json(MANAGER_CONN_PATH.read_text())
except Exception:
except (OSError, ValueError, Exception) as e:
# 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(
Expand All @@ -66,7 +68,8 @@ def connect_manager() -> HttpClient:
uds_path=conn.uds_path, host=conn.host, port=conn.port
):
break
except Exception:
except (OSError, ValueError, Exception):
# Failed to read/parse - retry in next iteration
pass
time.sleep(0.1)
else:
Expand Down
17 changes: 10 additions & 7 deletions src/lsp_cli/manager/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,21 @@

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__":
if IS_WINDOWS:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
assert isinstance(port, int)
conn = ConnectionInfo(host="127.0.0.1", port=port)
MANAGER_CONN_PATH.write_text(conn.model_dump_json())
uvicorn.run(app, host="127.0.0.1", port=port)
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())
# Pass the file descriptor to uvicorn to avoid race condition
uvicorn.run(app, host="127.0.0.1", port=port, fd=sock.fileno())
finally:
sock.close()
else:
MANAGER_UDS_PATH.unlink(missing_ok=True)
MANAGER_UDS_PATH.parent.mkdir(parents=True, exist_ok=True)
Expand Down
23 changes: 16 additions & 7 deletions src/lsp_cli/manager/client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import socket
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
Expand All @@ -16,6 +17,7 @@
from lsp_cli.client import TargetClient
from lsp_cli.manager.capability import CapabilityController, Capabilities
from lsp_cli.settings import IS_WINDOWS, LOG_DIR, RUNTIME_DIR, settings
from lsp_cli.utils.socket import allocate_port

from .models import ConnectionInfo, ManagedClientInfo

Expand All @@ -40,6 +42,7 @@ class ManagedClient:
_logger: loguru.Logger = field(init=False)
_logger_sink_id: int = field(init=False)
_port: int | None = field(init=False, default=None)
_socket: socket.socket | None = field(init=False, default=None)

def __attrs_post_init__(self) -> None:
self._deadline = anyio.current_time() + settings.idle_timeout
Expand Down Expand Up @@ -67,6 +70,11 @@ def id(self) -> str:
@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)

Expand Down Expand Up @@ -134,20 +142,17 @@ def exception_handler(request: Request, exc: Exception) -> Response:
)

if IS_WINDOWS:
import socket

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
port_val = s.getsockname()[1]
assert isinstance(port_val, int)
self._port = port_val
sock, port_val = allocate_port()
self._port = port_val
self._socket = sock # Keep reference to close later

config = uvicorn.Config(
app,
host="127.0.0.1",
port=port_val,
loop="asyncio",
log_config=None,
fd=sock.fileno(),
)
else:
config = uvicorn.Config(
Expand Down Expand Up @@ -182,6 +187,10 @@ async def run(self) -> None:
self._logger.info("Cleaning up client")
if not IS_WINDOWS:
await anyio.Path(self.uds_path).unlink(missing_ok=True)
else:
# Close the socket on Windows
if self._socket is not None:
self._socket.close()
self._logger.remove(self._logger_sink_id)
self._timeout_scope.cancel()
self._server_scope.cancel()
19 changes: 19 additions & 0 deletions src/lsp_cli/manager/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,31 @@


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"
Expand Down
40 changes: 27 additions & 13 deletions src/lsp_cli/manager/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@

from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from functools import cached_property
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,
Expand All @@ -25,19 +24,31 @@
@define
class ManagerServer(Server):
conn: ConnectionInfo
_client: httpx.AsyncClient | None = field(init=False, default=None)

@cached_property
@property
def client(self) -> httpx.AsyncClient:
if self.conn.uds_path:
transport = httpx.AsyncHTTPTransport(uds=self.conn.uds_path.as_posix())
else:
transport = httpx.AsyncHTTPTransport()
"""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()

return httpx.AsyncClient(
transport=transport,
base_url=self.conn.url,
timeout=None,
)
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:
Expand Down Expand Up @@ -81,4 +92,7 @@ async def run(
port=self.conn.port,
timeout=10.0,
)
yield self
try:
yield self
finally:
await self._close_client()
56 changes: 51 additions & 5 deletions src/lsp_cli/utils/socket.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@
from tenacity import AsyncRetrying, stop_after_delay, wait_fixed


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:
Expand All @@ -16,13 +42,15 @@ def is_server_alive(
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
Expand All @@ -34,24 +62,42 @@ async def wait_for_server(
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 for attempt in AsyncRetrying(
stop=stop_after_delay(timeout),
wait=wait_fixed(0.1),
reraise=True,
):
with attempt:
if uds_path:
if uds_path and has_uds_support:
try:
af_unix = getattr(socket, "AF_UNIX", None)
if af_unix is not None:
_ = await anyio.connect_unix(uds_path)
return
_ = 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")
41 changes: 30 additions & 11 deletions tests/test_cli_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,17 +182,36 @@ def test_manager_auto_start_reliability(self):
import os

if os.name == "nt":
subprocess.run(
[
"taskkill",
"/F",
"/IM",
"python.exe",
"/FI",
"MODULE == lsp_cli.manager",
],
capture_output=True,
)
# 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"],
Expand Down
Loading