Coverage for src / lilbee / cli / commands / servers.py: 100%
70 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-06-28 01:01 +0000
1"""Serve (HTTP API) and mcp (stdio) server-boot commands."""
3from __future__ import annotations
5import asyncio
6import contextlib
7import logging
8from pathlib import Path
9from typing import TYPE_CHECKING
11import typer
13from lilbee.cli.app import (
14 apply_overrides,
15 console,
16 data_dir_option,
17 global_option,
18)
19from lilbee.cli.commands.serve_logging import setup_server_log_file, setup_server_logging
20from lilbee.core.config import cfg
22if TYPE_CHECKING:
23 import uvicorn
26def _port_file() -> Path:
27 return cfg.data_dir / "server.port"
30def _log_loop_exception(_loop: asyncio.AbstractEventLoop, context: dict[str, object]) -> None:
31 exc = context.get("exception")
32 # isinstance: asyncio's context dict is untyped; "exception" may be absent
33 if isinstance(exc, BaseException):
34 logging.getLogger(__name__).error("asyncio task error", exc_info=exc)
35 else:
36 logging.getLogger(__name__).error("asyncio task error: %s", context.get("message"))
39async def _run_server(server: uvicorn.Server, config: uvicorn.Config, host: str) -> None:
40 """Start uvicorn, write port file, and clean up on shutdown."""
41 import atexit
43 from lilbee.parent_monitor import parse_parent_pid, watch_parent_async
45 loop = asyncio.get_running_loop()
46 loop.set_exception_handler(_log_loop_exception)
48 port_path = _port_file()
50 def _cleanup_port_file() -> None:
51 port_path.unlink(missing_ok=True)
53 if not config.loaded:
54 config.load()
55 server.lifespan = config.lifespan_class(config)
57 # `server.servers` is set inside `startup()`. The finally below must skip
58 # `shutdown()` when startup never ran: uvicorn dereferences `self.servers`
59 # there and the resulting AttributeError would mask the original failure.
60 started = False
61 parent_watcher: asyncio.Task[None] | None = None
62 try:
63 await server.startup()
64 started = True
66 parent_pid = parse_parent_pid()
67 if parent_pid is not None:
69 def _on_parent_death() -> None:
70 server.should_exit = True
72 parent_watcher = asyncio.create_task(watch_parent_async(parent_pid, _on_parent_death))
74 if server.servers:
75 sock = server.servers[0].sockets[0]
76 actual_port = sock.getsockname()[1]
77 port_path.parent.mkdir(parents=True, exist_ok=True)
78 port_path.write_text(str(actual_port))
79 atexit.register(_cleanup_port_file)
80 console.print(f"Listening on http://{host}:{actual_port}")
81 await server.main_loop()
82 finally:
83 if parent_watcher is not None and not parent_watcher.done():
84 parent_watcher.cancel()
85 port_path.unlink(missing_ok=True)
86 if started:
87 # Suppress AttributeError from a partial uvicorn bring-up so any
88 # original exception from main_loop reaches the caller intact.
89 with contextlib.suppress(AttributeError):
90 await server.shutdown()
93def serve(
94 host: str = typer.Option(None, "--host", "-H", help="Bind address (default: 127.0.0.1)"),
95 port: int = typer.Option(None, "--port", "-p", help="Port (default: 0/random)"),
96 data_dir: Path | None = data_dir_option,
97 use_global: bool = global_option,
98) -> None:
99 """Start the HTTP API server."""
100 apply_overrides(data_dir=data_dir, use_global=use_global)
101 if host is not None:
102 cfg.server_host = host
103 if port is not None:
104 cfg.server_port = port
106 setup_server_logging()
108 import uvicorn
110 from lilbee.server import create_app
112 logging.getLogger("asyncio").setLevel(logging.ERROR)
114 app = create_app()
115 # Litestar's app construction reconfigures root logging; re-install the file handler.
116 setup_server_log_file()
117 config = uvicorn.Config(app, host=cfg.server_host, port=cfg.server_port)
118 server = uvicorn.Server(config)
119 asyncio.run(_run_server(server, config, cfg.server_host))
122def mcp_cmd() -> None:
123 """Start the MCP server (stdio transport) for agent integration."""
124 from lilbee.mcp_server import main
126 main()