Coverage for src/lilbee/cli/commands/servers.py: 100%

144 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-11-06 08:17 +0000

1"""Serve (HTTP API) and mcp (stdio) server-boot commands.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import contextlib 

7import logging 

8import os 

9from pathlib import Path 

10from typing import TYPE_CHECKING, NoReturn 

11 

12import typer 

13 

14from lilbee.app.services import ( 

15 exit_after_signal, 

16 exit_if_opens_remain, 

17 report_library_upgrade, 

18 set_server_exit_hook, 

19 wait_for_hard_exit_teardown, 

20) 

21from lilbee.cli.app import ( 

22 apply_overrides, 

23 console, 

24 data_dir_option, 

25 global_option, 

26) 

27from lilbee.cli.commands.serve_logging import ( 

28 install_health_access_filter, 

29 setup_server_logging, 

30) 

31from lilbee.cli.upgrade_line import UpgradeLine 

32from lilbee.core.config import cfg 

33from lilbee.runtime.console import PlainConsole 

34from lilbee.runtime.lock import ( 

35 SERVER_LOCK_TIMEOUT, 

36 LockingUnsupportedError, 

37 acquire_scope_lock, 

38 acquire_server_lock, 

39 read_scope_owner, 

40) 

41 

42if TYPE_CHECKING: 

43 import uvicorn 

44 from filelock import FileLock 

45 

46 from lilbee.runtime.lock import ScopeHold 

47 

48 

49SCOPE_ENV = "LILBEE_EXCLUSIVE_SCOPE" 

50"""A directory that at most one server may serve at a time (a plugin's shared root).""" 

51 

52LOCK_REFUSAL_EXIT_CODE = 3 

53"""Exit code for a start refused because another live server holds a lock. 

54 

55Distinct from a generic failure so a supervisor can tell "another server owns 

56this" apart from a crash without parsing output. 

57""" 

58 

59 

60def port_file() -> Path: 

61 """Path to the running server's port file under ``cfg.data_dir``.""" 

62 return cfg.data_dir / "server.port" 

63 

64 

65def _log_loop_exception(_loop: asyncio.AbstractEventLoop, context: dict[str, object]) -> None: 

66 exc = context.get("exception") 

67 # isinstance: asyncio's context dict is untyped; "exception" may be absent 

68 if isinstance(exc, BaseException): 

69 logging.getLogger(__name__).error("asyncio task error", exc_info=exc) 

70 else: 

71 logging.getLogger(__name__).error("asyncio task error: %s", context.get("message")) 

72 

73 

74class _ExitRequest: 

75 """The exit a serving loop was asked for; a nonzero status ends the responses in flight.""" 

76 

77 def __init__(self, server: uvicorn.Server, loop: asyncio.AbstractEventLoop) -> None: 

78 self.status = 0 

79 self._server = server 

80 self._loop = loop 

81 

82 def _cancel_responses(self) -> None: 

83 for task in self._server.server_state.tasks: 

84 task.cancel() 

85 

86 def __call__(self, status: int) -> None: 

87 self.status = status 

88 self._server.should_exit = True 

89 if status: 

90 # A signal handler calls this, so the loop runs the cancel itself. 

91 self._loop.call_soon_threadsafe(self._cancel_responses) 

92 

93 

94async def _run_server(server: uvicorn.Server, config: uvicorn.Config, host: str) -> int: 

95 """Start uvicorn, write port file, clean up on shutdown, and return the exit status.""" 

96 import atexit 

97 

98 from lilbee.parent_monitor import parse_parent_pid, watch_parent_async 

99 

100 loop = asyncio.get_running_loop() 

101 loop.set_exception_handler(_log_loop_exception) 

102 

103 port_path = port_file() 

104 

105 def _cleanup_port_file() -> None: 

106 port_path.unlink(missing_ok=True) 

107 

108 exit_request = _ExitRequest(server, loop) 

109 

110 if not config.loaded: 

111 config.load() 

112 server.lifespan = config.lifespan_class(config) 

113 

114 # `server.servers` is set inside `startup()`. The finally below must skip 

115 # `shutdown()` when startup never ran: uvicorn dereferences `self.servers` 

116 # there and the resulting AttributeError would mask the original failure. 

117 started = False 

118 parent_watcher: asyncio.Task[None] | None = None 

119 try: 

120 set_server_exit_hook(exit_request) 

121 await server.startup() 

122 started = True 

123 

124 parent_pid = parse_parent_pid() 

125 if parent_pid is not None: 

126 

127 def _on_parent_death() -> None: 

128 server.should_exit = True 

129 

130 parent_watcher = asyncio.create_task(watch_parent_async(parent_pid, _on_parent_death)) 

131 

132 if server.servers: 

133 sock = server.servers[0].sockets[0] 

134 actual_port = sock.getsockname()[1] 

135 port_path.parent.mkdir(parents=True, exist_ok=True) 

136 port_path.write_text(str(actual_port), encoding="utf-8") 

137 atexit.register(_cleanup_port_file) 

138 console.print(f"Listening on http://{host}:{actual_port}") 

139 await server.main_loop() 

140 finally: 

141 set_server_exit_hook(None) 

142 if parent_watcher is not None and not parent_watcher.done(): 

143 parent_watcher.cancel() 

144 port_path.unlink(missing_ok=True) 

145 if started: 

146 # Suppress AttributeError from a partial uvicorn bring-up so any 

147 # original exception from main_loop reaches the caller intact. 

148 with contextlib.suppress(AttributeError): 

149 await server.shutdown() 

150 return exit_request.status 

151 

152 

153def _report_upgrade_as_log_lines() -> None: 

154 """Show a library upgrade of this server as plain stderr lines, beside its log.""" 

155 report_library_upgrade(UpgradeLine(PlainConsole(stderr=True), live=False)) 

156 

157 

158def _refuse_to_start(message: str) -> NoReturn: 

159 """Report why the server will not start, to the log and the terminal, then exit.""" 

160 logging.getLogger(__name__).error(message) 

161 console.print(message, soft_wrap=True) 

162 raise typer.Exit(LOCK_REFUSAL_EXIT_CODE) 

163 

164 

165def serve( 

166 host: str = typer.Option(None, "--host", "-H", help="Bind address (default: 127.0.0.1)"), 

167 port: int = typer.Option(None, "--port", "-p", help="Port (default: 0/random)"), 

168 data_dir: Path | None = data_dir_option, 

169 use_global: bool = global_option, 

170) -> None: 

171 """Start the HTTP API server.""" 

172 apply_overrides(data_dir=data_dir, use_global=use_global) 

173 _report_upgrade_as_log_lines() 

174 if host is not None: 

175 cfg.server_host = host 

176 if port is not None: 

177 cfg.server_port = port 

178 

179 setup_server_logging() 

180 

181 # One managed server per scope: the plugin passes its shared root here so a 

182 # second vault's server cannot start while another vault's is serving it. 

183 scope_hold = None 

184 scope_env = os.environ.get(SCOPE_ENV) 

185 if scope_env: 

186 scope_dir = Path(scope_env) 

187 try: 

188 scope_hold = acquire_scope_lock(scope_dir, cfg.data_dir, timeout=SERVER_LOCK_TIMEOUT) 

189 except LockingUnsupportedError as exc: 

190 _refuse_to_start(str(exc)) 

191 if scope_hold is None: 

192 owner = read_scope_owner(scope_dir) 

193 serving = f" It is serving {owner.data_dir}." if owner else "" 

194 message = ( 

195 f"Another lilbee server is already running for this installation.{serving}" 

196 " Stop it or wait for it to exit, then retry." 

197 ) 

198 _refuse_to_start(message) 

199 

200 # One server per data dir: a second instance would overwrite server.port 

201 # and spawn a second engine fleet against the same models and vector store. 

202 message = ( 

203 "Another lilbee server is already running for this data directory. " 

204 "Stop it or wait for it to exit, then retry." 

205 ) 

206 try: 

207 server_lock = acquire_server_lock(cfg.data_dir, timeout=SERVER_LOCK_TIMEOUT) 

208 except LockingUnsupportedError as exc: 

209 server_lock, message = None, str(exc) 

210 if server_lock is None: 

211 if scope_hold is not None: 

212 scope_hold.release() 

213 _refuse_to_start(message) 

214 

215 import uvicorn 

216 

217 from lilbee.server import create_app 

218 

219 logging.getLogger("asyncio").setLevel(logging.ERROR) 

220 

221 status = 1 

222 try: 

223 app = create_app() 

224 install_health_access_filter() 

225 config = uvicorn.Config(app, host=cfg.server_host, port=cfg.server_port) 

226 server = uvicorn.Server(config) 

227 status = asyncio.run(_run_server(server, config, cfg.server_host)) 

228 except Exception: 

229 # The exit in the finally can end the process before the traceback prints. 

230 logging.getLogger(__name__).error("The server run failed.", exc_info=True) 

231 raise 

232 finally: 

233 _stop_server(status, server_lock, scope_hold) 

234 raise typer.Exit(status) 

235 

236 

237def _stop_server(status: int, server_lock: FileLock, scope_hold: ScopeHold | None) -> None: 

238 """Free the locks after the teardown, then end the process if a library open still runs.""" 

239 # A signal-driven shutdown stops the fleet on its own thread; hold the 

240 # locks until it finishes so a successor cannot start while this 

241 # server's models still occupy memory. 

242 wait_for_hard_exit_teardown() 

243 server_lock.release() 

244 if scope_hold is not None: 

245 scope_hold.release() 

246 exit_if_opens_remain(status) 

247 

248 

249def mcp_cmd( 

250 data_dir: Path | None = data_dir_option, 

251 use_global: bool = global_option, 

252) -> None: 

253 """Start the MCP server (stdio transport) for agent integration.""" 

254 apply_overrides(data_dir=data_dir, use_global=use_global) 

255 _report_upgrade_as_log_lines() 

256 from lilbee.mcp_server import main 

257 

258 try: 

259 main() 

260 except SystemExit as exc: 

261 exit_after_signal(exc.code if isinstance(exc.code, int) else 1) 

262 raise