Skip to content
This repository was archived by the owner on Jan 13, 2026. It is now read-only.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 11 additions & 5 deletions src/lsp_cli/cli/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
48 changes: 43 additions & 5 deletions src/lsp_cli/manager/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,18 +35,55 @@


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,
stdout=subprocess.DEVNULL,
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,
)
)
29 changes: 25 additions & 4 deletions src/lsp_cli/manager/__main__.py
Original file line number Diff line number Diff line change
@@ -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))
70 changes: 56 additions & 14 deletions src/lsp_cli/manager/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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:
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential None value: The conn property on Windows returns ConnectionInfo with self._port, but _port is initialized to None and only set during _serve(). If conn is accessed before _serve() is called, it will return ConnectionInfo with port=None, which may cause issues in code expecting a valid port. Consider initializing the port earlier or documenting this constraint.

Suggested change
if IS_WINDOWS:
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."
)

Copilot uses AI. Check for mistakes.
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"
Expand Down Expand Up @@ -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:
Expand All @@ -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()
11 changes: 7 additions & 4 deletions src/lsp_cli/manager/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from .client import ManagedClient, get_client_id
from .models import (
ConnectionInfo,
CreateClientRequest,
CreateClientResponse,
DeleteClientRequest,
Expand Down Expand Up @@ -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}")
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 32 additions & 1 deletion src/lsp_cli/manager/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Copy link

Copilot AI Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing documentation: The ConnectionInfo.url property returns "http://localhost" as a fallback when host and port are not set. This fallback behavior should be documented, as it may not be obvious when this occurs and could lead to connection issues if callers expect a valid connection URL.

Suggested change
def url(self) -> str:
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"``.
"""

Copilot uses AI. Check for mistakes.
"""
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
Expand All @@ -31,7 +62,7 @@ class CreateClientRequest(BaseModel):


class CreateClientResponse(BaseModel):
uds_path: Path
conn: ConnectionInfo
info: ManagedClientInfo


Expand Down
Loading
Loading