Skip to content

Commit 0e241e7

Browse files
committed
WIP: servers daemon
1 parent c460493 commit 0e241e7

File tree

4 files changed

+259
-0
lines changed

4 files changed

+259
-0
lines changed

src/lsp_client/cli/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from __future__ import annotations
2+
3+
from .__main__ import app
4+
5+
__all__ = ["app"]

src/lsp_client/cli/__main__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33
import typer
44

5+
from . import servers
6+
57
app = typer.Typer(help="LSP Client CLI")
68

9+
app.add_typer(servers.app, name="servers")
10+
711

812
@app.command()
913
def hover(

src/lsp_client/cli/daemon.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from pathlib import Path
5+
from typing import Literal
6+
7+
import anyio
8+
from loguru import logger
9+
from pydantic import BaseModel, Field
10+
11+
SOCKET_PATH = Path("/tmp/lsp_client_daemon.sock")
12+
13+
14+
class ServerInfo(BaseModel):
15+
image: str
16+
status: str = "running"
17+
18+
19+
class CreateParams(BaseModel):
20+
name: str
21+
image: str
22+
23+
24+
class StopParams(BaseModel):
25+
name: str
26+
27+
28+
class ListParams(BaseModel): ...
29+
30+
31+
class DaemonRequest(BaseModel):
32+
command: Literal["create", "list", "stop"]
33+
params: CreateParams | StopParams | ListParams = Field(default_factory=ListParams)
34+
35+
36+
class DaemonResponse(BaseModel):
37+
status: str
38+
message: str | None = None
39+
servers: dict[str, ServerInfo] | None = None
40+
41+
42+
class Daemon:
43+
def __init__(self):
44+
self.servers: dict[str, ServerInfo] = {}
45+
46+
async def handle_client(
47+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
48+
):
49+
try:
50+
data = await reader.read(4096)
51+
if not data:
52+
return
53+
54+
request = DaemonRequest.model_validate_json(data.decode())
55+
56+
response = await self.process_command(request)
57+
writer.write(response.model_dump_json().encode())
58+
await writer.drain()
59+
except Exception as e:
60+
logger.error(f"Error handling client: {e}")
61+
error_response = DaemonResponse(status="error", message=str(e))
62+
writer.write(error_response.model_dump_json().encode())
63+
await writer.drain()
64+
finally:
65+
writer.close()
66+
await writer.wait_closed()
67+
68+
async def process_command(self, request: DaemonRequest) -> DaemonResponse:
69+
params = request.params
70+
match request.command:
71+
case "create":
72+
if not isinstance(params, CreateParams):
73+
return DaemonResponse(
74+
status="error", message="Invalid parameters for create"
75+
)
76+
name = params.name
77+
image = params.image
78+
79+
if name in self.servers:
80+
return DaemonResponse(
81+
status="error", message=f"Server {name} already exists"
82+
)
83+
84+
self.servers[name] = ServerInfo(image=image)
85+
return DaemonResponse(
86+
status="success",
87+
message=f"Server {name} created with image {image}",
88+
)
89+
90+
case "list":
91+
return DaemonResponse(status="success", servers=self.servers)
92+
93+
case "stop":
94+
if not isinstance(params, StopParams):
95+
return DaemonResponse(
96+
status="error", message="Invalid parameters for stop"
97+
)
98+
name = params.name
99+
if name not in self.servers:
100+
return DaemonResponse(
101+
status="error", message=f"Server {name} not found"
102+
)
103+
104+
del self.servers[name]
105+
return DaemonResponse(
106+
status="success", message=f"Server {name} stopped"
107+
)
108+
109+
case _:
110+
return DaemonResponse(
111+
status="error", message=f"Unknown command: {request.command}"
112+
)
113+
114+
async def run(self):
115+
if SOCKET_PATH.exists():
116+
SOCKET_PATH.unlink()
117+
118+
server = await asyncio.start_unix_server(
119+
self.handle_client, path=str(SOCKET_PATH)
120+
)
121+
async with server:
122+
await server.serve_forever()
123+
124+
125+
if __name__ == "__main__":
126+
daemon = Daemon()
127+
anyio.run(daemon.run)

src/lsp_client/cli/servers.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import socket
5+
import time
6+
7+
import typer
8+
from rich.console import Console
9+
from rich.table import Table
10+
11+
from lsp_client.cli.daemon import (
12+
SOCKET_PATH,
13+
CreateParams,
14+
DaemonRequest,
15+
DaemonResponse,
16+
ListParams,
17+
StopParams,
18+
start_daemon,
19+
)
20+
21+
app = typer.Typer(help="Manage LSP servers")
22+
console = Console()
23+
24+
25+
def ensure_daemon():
26+
"""Ensure the daemon is running."""
27+
if not SOCKET_PATH.exists():
28+
start_daemon()
29+
# Give it a moment to start
30+
for _ in range(10):
31+
if SOCKET_PATH.exists():
32+
break
33+
time.sleep(0.1)
34+
35+
# Try to connect to verify it's responsive
36+
try:
37+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
38+
s.settimeout(1.0)
39+
s.connect(str(SOCKET_PATH))
40+
except (TimeoutError, ConnectionRefusedError):
41+
if SOCKET_PATH.exists():
42+
SOCKET_PATH.unlink()
43+
start_daemon()
44+
for _ in range(10):
45+
if SOCKET_PATH.exists():
46+
break
47+
time.sleep(0.1)
48+
49+
50+
async def send_command(request: DaemonRequest) -> DaemonResponse:
51+
"""Send a command to the daemon and return the response."""
52+
ensure_daemon()
53+
54+
reader, writer = await asyncio.open_unix_connection(str(SOCKET_PATH))
55+
try:
56+
writer.write(request.model_dump_json().encode())
57+
await writer.drain()
58+
59+
data = await reader.read(4096)
60+
if not data:
61+
return DaemonResponse(status="error", message="No response from daemon")
62+
63+
return DaemonResponse.model_validate_json(data.decode())
64+
finally:
65+
writer.close()
66+
await writer.wait_closed()
67+
68+
69+
@app.command()
70+
def create(
71+
name: str = typer.Argument(..., help="Name of the server instance"),
72+
image: str = typer.Argument(..., help="Docker image to use"),
73+
):
74+
"""Create a new LSP server instance."""
75+
request = DaemonRequest(
76+
command="create", params=CreateParams(name=name, image=image)
77+
)
78+
response = asyncio.run(send_command(request))
79+
if response.status == "success":
80+
console.print(f"[green]{response.message}[/green]")
81+
else:
82+
console.print(f"[red]Error: {response.message}[/red]")
83+
84+
85+
@app.command(name="list")
86+
def list_servers():
87+
"""List all running LSP server instances."""
88+
request = DaemonRequest(command="list", params=ListParams())
89+
response = asyncio.run(send_command(request))
90+
if response.status == "success":
91+
servers = response.servers or {}
92+
if not servers:
93+
console.print("No servers running.")
94+
return
95+
96+
table = Table(title="LSP Servers")
97+
table.add_column("Name", style="cyan")
98+
table.add_column("Image", style="magenta")
99+
table.add_column("Status", style="green")
100+
101+
for name, info in servers.items():
102+
table.add_row(name, info.image, info.status)
103+
104+
console.print(table)
105+
else:
106+
console.print(f"[red]Error: {response.message}[/red]")
107+
108+
109+
@app.command()
110+
def stop(
111+
name: str = typer.Argument(..., help="Name of the server instance to stop"),
112+
):
113+
"""Stop an LSP server instance."""
114+
request = DaemonRequest(command="stop", params=StopParams(name=name))
115+
response = asyncio.run(send_command(request))
116+
if response.status == "success":
117+
console.print(f"[green]{response.message}[/green]")
118+
else:
119+
console.print(f"[red]Error: {response.message}[/red]")
120+
121+
122+
if __name__ == "__main__":
123+
app()

0 commit comments

Comments
 (0)