Coverage for src/lilbee/app/services.py: 100%

305 statements  

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

1"""Typed service container: single point of access for all singletons. 

2 

3All runtime dependencies (provider, store, embedder, reranker, concepts, 

4clusterer, searcher, worker pool) are created lazily on first call to 

5``get_services()`` and cached for the process lifetime. Tests call 

6``reset_services()`` between runs. 

7 

8``build_services(config)`` is the construction seam: it builds a full container 

9against an arbitrary Config without touching the process-global singleton. The 

10library API (:class:`lilbee.Lilbee`) builds one per instance and installs it for 

11the duration of each call via :func:`services_scope`, so ingest code reaching for 

12``get_services()`` resolves the caller's container. The override is a ContextVar, 

13so ``reset_services`` / ``set_services`` / ``peek_services`` (which operate only 

14on the global singleton) never see it. 

15""" 

16 

17from __future__ import annotations 

18 

19import asyncio 

20import atexit 

21import logging 

22import os 

23import signal 

24import sys 

25import threading 

26import time 

27import weakref 

28from collections.abc import Callable 

29from contextlib import contextmanager, suppress 

30from contextvars import ContextVar 

31from dataclasses import dataclass, field 

32from typing import TYPE_CHECKING 

33 

34if TYPE_CHECKING: 

35 from collections.abc import Iterator 

36 

37 from lilbee.catalog.hf_client import HfClient 

38 from lilbee.core.config import Config 

39 from lilbee.core.profile_files import ProfileStore 

40 from lilbee.data.store import Store 

41 from lilbee.modelhub.model_manager import ModelManager 

42 from lilbee.modelhub.model_manager.discovery import KnownModelCache 

43 from lilbee.modelhub.registry import ModelRegistry 

44 from lilbee.providers.base import LLMProvider 

45 from lilbee.providers.roles import WorkerRole 

46 from lilbee.retrieval.clustering import Clusterer 

47 from lilbee.retrieval.concepts import ConceptGraph 

48 from lilbee.retrieval.embedder import Embedder 

49 from lilbee.retrieval.query import Searcher 

50 from lilbee.retrieval.reranker import Reranker 

51 from lilbee.runtime.ingest_lock import IngestLockRegistry 

52 from lilbee.runtime.progress import DetailedProgressCallback, EventType, ProgressEvent 

53 from lilbee.sessions import SessionStore 

54 

55 

56log = logging.getLogger(__name__) 

57 

58_SIGNAL_EXIT_BASE = 128 

59 

60_HARD_EXIT_THREAD_NAME = "hard-exit-teardown" 

61 

62 

63def _default_session_store() -> SessionStore: 

64 """Build the file-backed session store, importing it lazily. 

65 

66 ``lilbee.sessions`` pulls in the config/catalog import chain, so importing it 

67 at this module's top would form a cycle during CLI config load. 

68 """ 

69 from lilbee.sessions import SessionStore 

70 

71 return SessionStore() 

72 

73 

74def _default_profile_store() -> ProfileStore: 

75 """Build the profile store, importing it lazily.""" 

76 # circular: core.config -> catalog (model-ref validator) -> app.services -> core.profile_files 

77 from lilbee.core.profile_files import ProfileStore 

78 

79 return ProfileStore() 

80 

81 

82@dataclass 

83class CrawlerSyncState: 

84 """Process-wide sync coordination state (lock + last-run timestamp).""" 

85 

86 lock: threading.Lock = field(default_factory=threading.Lock) 

87 last_run: float = 0.0 

88 

89 

90@dataclass(frozen=True) 

91class Services: 

92 """Holds all runtime service instances. 

93 

94 Inference lifecycle (cancel, per-role reload, spawn notifications) is owned 

95 by the provider, which manages the llama-server fleet. Services exposes thin 

96 pass-throughs so callers (Ctrl+C, the chat-stream cancel action, the settings 

97 and model-bar pickers, the TUI task bar) need not reach into the provider's 

98 API. ``cancel_inference()`` is the canonical cancel entry point. 

99 """ 

100 

101 provider: LLMProvider 

102 store: Store 

103 embedder: Embedder 

104 reranker: Reranker 

105 concepts: ConceptGraph 

106 clusterer: Clusterer 

107 searcher: Searcher 

108 registry: ModelRegistry 

109 hf_client: HfClient 

110 ingest_lock_registry: IngestLockRegistry 

111 model_manager: ModelManager 

112 crawler_semaphore: asyncio.Semaphore | None 

113 crawler_sync_state: CrawlerSyncState 

114 known_models: KnownModelCache 

115 session_store: SessionStore = field(default_factory=_default_session_store) 

116 profile_store: ProfileStore = field(default_factory=_default_profile_store) 

117 

118 def cancel_inference(self) -> None: 

119 """Interrupt any in-flight generation. Idempotent. 

120 

121 The fleet engine severs its live chat streams (llama-server stops 

122 generating when the connection drops); providers with nothing in 

123 flight treat this as a no-op. 

124 """ 

125 self.provider.cancel_inference() 

126 

127 def reload_role(self, role_name: WorkerRole, *, wait: bool = False) -> None: 

128 """Respawn only *role_name*'s model server so it picks up changed cfg. 

129 

130 Other roles' servers and any in-flight stream they own are untouched. Use 

131 when one role-bound model setting changed (e.g. embedding_model). The 

132 respawn runs off the caller's thread, so this returns immediately, unless 

133 ``wait=True`` (the caller is already off the event loop and wants to block 

134 until the new model has loaded). 

135 """ 

136 self.provider.reload_role(role_name, wait=wait) 

137 

138 def add_pool_listener( 

139 self, 

140 *, 

141 on_spawning: Callable[[WorkerRole], None] | None = None, 

142 on_spawned: Callable[[WorkerRole], None] | None = None, 

143 ) -> None: 

144 """Subscribe to server spawn lifecycle events. 

145 

146 Forwards to :meth:`LLMProvider.add_spawn_listener`. The TUI uses this to 

147 surface "Starting <role>..." / "<role> ready" notifications when a role's 

148 server (re)spawns (cold start after a non-eager boot, or a reload). 

149 """ 

150 self.provider.add_spawn_listener(on_spawning=on_spawning, on_spawned=on_spawned) 

151 

152 

153class _ServicesState: 

154 """The cached process-global singleton plus the per-task scoped override. 

155 

156 ``singleton`` is set on first ``get_services()`` call. Concurrency 

157 contract: creation is serialized by ``_singleton_create_lock`` (several 

158 worker threads can first-touch services at once, and a duplicate build 

159 would collide in xberg's process-global backend registry), and the 

160 Services dataclass is logically immutable post-construction, so concurrent 

161 reads are safe without a lock. Tests that need a custom container call 

162 ``set_services(make_mock_services(...))``; ``peek_services()`` is the 

163 read-only inspector for cleanup fixtures. 

164 

165 ``override`` shadows the singleton for the entering task: set by 

166 :func:`services_scope` (the library API's per-call binding), read by 

167 :func:`get_services`, and invisible to ``reset_services`` / 

168 ``set_services`` / ``peek_services``, which only touch the singleton. 

169 """ 

170 

171 def __init__(self) -> None: 

172 self.singleton: Services | None = None 

173 self.override: ContextVar[Services | None] = ContextVar( 

174 "lilbee_services_override", default=None 

175 ) 

176 # Whether this process is an interactive session (the TUI). Recorded by 

177 # the interactive entry point before anything builds the container, and 

178 # read once at build so the provider it creates holds its fleet resident 

179 # for the session. Build-time intent only; the state that matters after 

180 # that lives on the provider itself. 

181 self.interactive: bool = False 

182 # Where this process shows a library upgrade; each surface sets its own. 

183 self.on_upgrade: DetailedProgressCallback | None = None 

184 # How this process takes config.toml now; set by each surface that follows the file. 

185 self.config_refresh: Callable[[], None] | None = None 

186 # Every store this process built, so the exit can wait for any library open 

187 # (the first, the one /reset makes, a lazy one) without each path telling it. 

188 self.stores: weakref.WeakSet[Store] = weakref.WeakSet() 

189 self.stores_lock = threading.Lock() 

190 

191 def report_upgrade(self, event_type: EventType, event: ProgressEvent) -> None: 

192 """Pass a library upgrade event to the reporter the surface set last, if any.""" 

193 if self.on_upgrade is not None: 

194 self.on_upgrade(event_type, event) 

195 

196 

197_state = _ServicesState() 

198 

199 

200def _new_store(config: Config) -> Store: 

201 """Build a Store and record it, so ``wait_for_opens`` sees its open; the one way to make one.""" 

202 from lilbee.data.store import Store 

203 

204 store = Store(config, on_upgrade=_state.report_upgrade) 

205 with _state.stores_lock: 

206 _state.stores.add(store) 

207 return store 

208 

209 

210def wait_for_opens(timeout: float) -> bool: 

211 """Wait up to *timeout* seconds in all, for every library open still running. 

212 

213 Return False when an open is still running at the deadline. 

214 """ 

215 deadline = time.monotonic() + timeout 

216 with _state.stores_lock: 

217 stores = list(_state.stores) 

218 done = True 

219 for store in stores: 

220 if not store.wait_for_open(max(0.0, deadline - time.monotonic())): 

221 done = False 

222 return done 

223 

224 

225def build_services( 

226 config: Config, 

227 *, 

228 provider: LLMProvider | None = None, 

229 registry: ModelRegistry | None = None, 

230 interactive: bool = False, 

231) -> Services: 

232 """Build a full Services container bound to *config*, without caching it. 

233 

234 ``get_services()`` calls this with the process-global cfg; the library API 

235 calls it per instance. Service modules are imported inside the function to 

236 keep CLI startup fast (they transitively pull in lancedb / xberg). Pass 

237 *provider* to reuse a caller-supplied one; otherwise it is built from 

238 *config* via the provider factory. Pass *registry* to reuse one already built 

239 (get_services builds it for embedding-dim reconciliation). Embedding-dim 

240 reconciliation is a global-cfg concern owned by :func:`get_services`, not 

241 done here. 

242 

243 Side effect: binds *provider* into xberg's process-global OCR and embedding 

244 backends (the chunker binds the tokenizer on demand). The registry is one 

245 process-wide slot, so every container (singleton or per-instance library) 

246 binds its own, not only get_services. 

247 """ 

248 from lilbee.catalog.hf_client import HfClient 

249 from lilbee.modelhub.model_manager import ModelManager 

250 from lilbee.modelhub.model_manager.discovery import KnownModelCache 

251 from lilbee.modelhub.registry import ModelRegistry 

252 from lilbee.modelhub.role_validator import warn_unregistered_role_refs 

253 from lilbee.providers.factory import create_provider 

254 from lilbee.retrieval.clustering import Clusterer 

255 from lilbee.retrieval.concepts import ConceptGraph 

256 from lilbee.retrieval.embedder import Embedder 

257 from lilbee.retrieval.query import Searcher 

258 from lilbee.retrieval.reranker import Reranker 

259 from lilbee.runtime.ingest_lock import IngestLockRegistry 

260 

261 provider = provider or create_provider( 

262 config, hold_warm=interactive, config_refresh=refresh_config 

263 ) 

264 from lilbee.data.extract.backends import sync_xberg_backends 

265 

266 sync_xberg_backends(provider) 

267 registry = registry or ModelRegistry(config.models_dir) 

268 # The first point that sees env vars, --model and config.toml together. 

269 warn_unregistered_role_refs(config, registry) 

270 store = _new_store(config) 

271 embedder = Embedder(config, provider) 

272 reranker = Reranker(config) 

273 concepts = ConceptGraph(config, store) 

274 clusterer = Clusterer(config, store) 

275 searcher = Searcher(config, provider, store, embedder, reranker, concepts) 

276 hf_client = HfClient() 

277 ingest_lock_registry = IngestLockRegistry() 

278 model_manager = ModelManager(config.models_dir) 

279 crawler_semaphore = ( 

280 asyncio.Semaphore(config.crawl_max_concurrent) if config.crawl_max_concurrent > 0 else None 

281 ) 

282 crawler_sync_state = CrawlerSyncState() 

283 known_models = KnownModelCache() 

284 return Services( 

285 provider=provider, 

286 store=store, 

287 embedder=embedder, 

288 reranker=reranker, 

289 concepts=concepts, 

290 clusterer=clusterer, 

291 searcher=searcher, 

292 registry=registry, 

293 hf_client=hf_client, 

294 ingest_lock_registry=ingest_lock_registry, 

295 model_manager=model_manager, 

296 crawler_semaphore=crawler_semaphore, 

297 crawler_sync_state=crawler_sync_state, 

298 known_models=known_models, 

299 ) 

300 

301 

302# Serializes first-touch singleton creation: several worker threads (e.g. 

303# concurrent downloads) can call get_services() before the singleton exists, 

304# and a duplicate build re-registers xberg's process-global backends mid-flight, 

305# raising 'already registered' in the losing thread. 

306_singleton_create_lock = threading.Lock() 

307 

308 

309def get_services() -> Services: 

310 """Return the active container: a scoped override if set, else the cached singleton. 

311 

312 Creates the singleton on first call (against the process-global cfg). A 

313 config-file embedding_model with no embedding_dim would otherwise build the 

314 store at the stale 768 default, so the width is pinned to the embedder before 

315 the store is built. 

316 """ 

317 override = _state.override.get() 

318 if override is not None: 

319 return override 

320 if _state.singleton is not None: 

321 return _state.singleton 

322 

323 from lilbee.core.config import cfg 

324 

325 with _singleton_create_lock: 

326 if _state.singleton is not None: 

327 return _state.singleton 

328 

329 from lilbee.app.settings import reconcile_embedding_dim 

330 from lilbee.modelhub.registry import ModelRegistry 

331 

332 registry = ModelRegistry(cfg.models_dir) 

333 # Pin the store width to the embedder before Store(); pass the registry so 

334 # resolution doesn't re-enter this half-built get_services. 

335 reconcile_embedding_dim(registry) 

336 services = build_services(cfg, registry=registry, interactive=_state.interactive) 

337 _state.singleton = services 

338 # Eager start is the default: pay the spawn cost per role server at TUI mount 

339 # so the first user action lands on a warm fleet. Roles whose model is unset 

340 # are skipped, so a setup with only chat + embed never spawns rerank or 

341 # vision. Set ``cfg.worker_pool_eager_start = false`` for headless scripts 

342 # where mount time matters more than first-call latency. 

343 if cfg.worker_pool_eager_start: 

344 from contextlib import suppress 

345 

346 with suppress(Exception): 

347 services.provider.warm_up_pool() 

348 return services 

349 

350 

351@contextmanager 

352def services_scope(services: Services) -> Iterator[None]: 

353 """Bind *services* as the container ``get_services()`` returns for this block. 

354 

355 Isolated to the entering task via a ContextVar (it propagates into 

356 ``to_ingest_thread`` workers), and never affects the global singleton, so 

357 ``reset_services`` is unnecessary and unused around a scoped call. 

358 """ 

359 token = _state.override.set(services) 

360 try: 

361 yield 

362 finally: 

363 _state.override.reset(token) 

364 

365 

366def mark_interactive_session() -> None: 

367 """Record that this process is an interactive session before services build. 

368 

369 The TUI owns the process for its whole lifetime, so an engine bound to it 

370 keeps its weights resident rather than idle-unloading under a user who is 

371 still in the app; closing lilbee releases it. An engine that outlives the 

372 session (shared with its other users, or kept warm) keeps its idle window 

373 instead. Called by the interactive entry point before anything touches 

374 ``get_services``, so the provider is constructed with that intent; a 

375 one-shot CLI or the MCP server never calls it. 

376 """ 

377 _state.interactive = True 

378 

379 

380def set_config_refresh(hook: Callable[[], None] | None) -> None: 

381 """Register how this process takes config.toml changes now (None clears it). 

382 

383 serve, the stdio MCP server and the TUI register the check they already run, so 

384 the local engine can take an owner's new settings before it retries a call. 

385 """ 

386 _state.config_refresh = hook 

387 

388 

389def refresh_config() -> None: 

390 """Take config.toml changes now through the registered check; nothing without one.""" 

391 hook = _state.config_refresh 

392 if hook is not None: 

393 hook() 

394 

395 

396def report_library_upgrade(callback: DetailedProgressCallback | None) -> None: 

397 """Show this process's library upgrades through *callback* (None: nowhere), for every store.""" 

398 _state.on_upgrade = callback 

399 

400 

401def set_services(services: Services | None) -> None: 

402 """Replace the cached Services singleton (for testing).""" 

403 _state.singleton = services 

404 

405 

406def chunks_in_store() -> bool: 

407 """True when the index holds a chunk, asked of the store alone. 

408 

409 A bound container (scoped or cached) answers with its own store. Without 

410 one, a short-lived store answers and no provider is built, so the check 

411 starts no model warm-up. 

412 """ 

413 from lilbee.core.config import cfg 

414 

415 bound = _state.override.get() or _state.singleton 

416 if bound is not None: 

417 return bound.store.has_chunks() 

418 store = _new_store(cfg) 

419 try: 

420 return store.has_chunks() 

421 finally: 

422 store.close() 

423 

424 

425def peek_services() -> Services | None: 

426 """Return the cached Services container, or None if not yet initialized. 

427 

428 Public read-only accessor for test cleanup helpers that need to 

429 inspect the singleton without forcing initialization. 

430 """ 

431 return _state.singleton 

432 

433 

434# Serializes the singleton swap in reset_services: a signal's teardown thread 

435# and an exiting caller's can race, and both tearing down the same container 

436# would double-close the store. 

437_reset_swap_lock = threading.Lock() 

438 

439 

440def reset_services() -> None: 

441 """Shut down and discard all cached instances. 

442 

443 Swap the module reference to ``None`` *before* tearing the old instances 

444 down, so a new caller never observes a half-closed container. The swap is 

445 locked so concurrent callers (a signal's teardown thread plus an exiting 

446 one) tear the container down exactly once. On the shared HTTP daemon every 

447 entry point that would call this mid-flight is refused, so it only ever 

448 runs single-client (CLI, TUI, stdio MCP). 

449 """ 

450 with _reset_swap_lock: 

451 old = _state.singleton 

452 _state.singleton = None 

453 if old is not None: 

454 old.provider.shutdown() 

455 old.store.close() 

456 

457 

458def reset_store() -> None: 

459 """Close and rebuild only the Store and its dependents; keep providers loaded. 

460 

461 Used after a data-dir wipe (``/reset``) where the LanceDB handle is invalid 

462 but the running provider/embedder/reranker are still good. Avoids the 

463 multi-second reload cost of ``reset_services()``. 

464 """ 

465 svc = _state.singleton 

466 if svc is None: 

467 return 

468 from dataclasses import replace 

469 

470 from lilbee.core.config import cfg 

471 from lilbee.retrieval.clustering import Clusterer 

472 from lilbee.retrieval.concepts import ConceptGraph 

473 from lilbee.retrieval.query import Searcher 

474 

475 # Build the replacement, swap the reference, then close the old store last so 

476 # a new caller never observes a closed handle mid-swap. 

477 old_store = svc.store 

478 store = _new_store(cfg) 

479 concepts = ConceptGraph(cfg, store) 

480 clusterer = Clusterer(cfg, store) 

481 searcher = Searcher(cfg, svc.provider, store, svc.embedder, svc.reranker, concepts) 

482 _state.singleton = replace( 

483 svc, 

484 store=store, 

485 concepts=concepts, 

486 clusterer=clusterer, 

487 searcher=searcher, 

488 ) 

489 old_store.close() 

490 

491 

492class _ServerExit: 

493 """Holds the running server's graceful-exit callback while it serves.""" 

494 

495 def __init__(self) -> None: 

496 self._hook: Callable[[int], None] | None = None 

497 

498 def set(self, hook: Callable[[int], None] | None) -> None: 

499 self._hook = hook 

500 

501 def request(self, status: int) -> bool: 

502 """Run the hook with the exit *status*; False when no server registered one.""" 

503 if self._hook is None: 

504 return False 

505 self._hook(status) 

506 return True 

507 

508 

509_server_exit = _ServerExit() 

510 

511 

512def set_server_exit_hook(hook: Callable[[int], None] | None) -> None: 

513 """Register the running server's graceful-exit callback (None clears it).""" 

514 _server_exit.set(hook) 

515 

516 

517def request_server_exit(status: int = 0) -> bool: 

518 """Ask the running server to exit with *status*; False when none registered. 

519 

520 A nonzero *status* is a hard exit: the server does not wait for responses in flight. 

521 """ 

522 return _server_exit.request(status) 

523 

524 

525class _EngineLifecycle: 

526 """Owns the hard-exit hooks that stop the engine fleet.""" 

527 

528 def __init__(self) -> None: 

529 self._installed = False 

530 

531 @staticmethod 

532 def _hard_exit_signals() -> tuple[signal.Signals, ...]: # pragma: no cover - platform split 

533 """Signals whose default disposition kills us without running atexit.""" 

534 if sys.platform == "win32": # Windows has no SIGHUP 

535 return (signal.SIGTERM,) 

536 return (signal.SIGTERM, signal.SIGHUP) 

537 

538 def install(self) -> None: 

539 """Route hard-exit signals through teardown. Idempotent; no-op off the main thread.""" 

540 if self._installed: 

541 return 

542 try: 

543 for sig in self._hard_exit_signals(): 

544 signal.signal(sig, self._on_hard_exit) 

545 except ValueError: 

546 return 

547 self._installed = True 

548 

549 def route_in_loop(self, loop: asyncio.AbstractEventLoop) -> None: 

550 """Deliver Ctrl-C and the hard-exit signals as loop callbacks. 

551 

552 A plain handler runs only when the main thread returns to bytecode, and the 

553 kernel may hand the signal to another thread while the loop sits in select. 

554 The loop's wakeup descriptor makes the delivery certain. 

555 """ 

556 for sig in (signal.SIGINT, *self._hard_exit_signals()): 

557 with suppress(NotImplementedError, RuntimeError): # Windows, off main thread 

558 loop.add_signal_handler(sig, self._on_hard_exit, sig, None) 

559 

560 def reset(self) -> None: 

561 """Forget that handlers were installed.""" 

562 self._installed = False 

563 

564 def _on_hard_exit(self, signum: int, frame: object) -> None: 

565 """Stop the fleet on its own thread, then exit with the signal status. 

566 

567 A serving loop is asked to stop and exits with the status itself. A 

568 SystemExit raised into the loop cancels the ASGI lifespan with it, 

569 which logs a traceback, or dies inside a request's ASGI wrapper. With 

570 no serving loop the SystemExit unwinds the main thread. Signal handlers 

571 all run on the main thread, and a second signal can interrupt this one 

572 mid-teardown: the kernel pairs SIGCONT with SIGHUP for an orphaned 

573 process group, and Textual's SIGCONT handler raises once the event loop 

574 is gone, which aborted the reap half-done and orphaned a loaded fleet. 

575 A dedicated non-daemon thread cannot be interrupted by signals, and the 

576 interpreter waits for it even as the SystemExit unwinds the main thread. 

577 """ 

578 del frame 

579 status = _SIGNAL_EXIT_BASE + signum 

580 serving = request_server_exit(status) 

581 threading.Thread( 

582 target=_teardown_for_signal, args=(signum,), name=_HARD_EXIT_THREAD_NAME 

583 ).start() 

584 if not serving: 

585 raise SystemExit(status) 

586 

587 

588_FORCE_QUIT_EXIT_CODE = 130 

589 

590_STOPPING_NOTE = "lilbee: stopping the engine. Press Ctrl-C again to force quit.\n" 

591 

592_STOPPING_NOTE_GRACE_S = 1.0 

593"""Seconds a teardown may run silently; a fast exit stays as quiet as ever.""" 

594 

595_FORCE_QUIT_NOTE = "lilbee: force quit. The engine stops with it.\n" 

596 

597_STRAGGLER_BUDGET_S = 5.0 

598"""Seconds every leftover non-daemon thread gets, together, before the exit.""" 

599 

600_STRAGGLER_NOTE = "lilbee: a background task would not stop; exiting anyway.\n" 

601 

602_OPEN_EXIT_NOTE = "lilbee: the library is still opening; exiting now. The next start finishes it.\n" 

603 

604 

605def _write_exit_note(note: str) -> None: 

606 """Print an exit-path status line; stderr may already be gone at exit.""" 

607 try: 

608 sys.stderr.write(note) 

609 sys.stderr.flush() 

610 except (OSError, ValueError): 

611 pass 

612 

613 

614def _flush_logs() -> None: 

615 """Flush every log handler and stdio; ``os._exit`` drops what a buffer still holds.""" 

616 # loggerDict also holds PlaceHolder entries for loggers that have no object yet. 

617 named = [e for e in logging.Logger.manager.loggerDict.values() if isinstance(e, logging.Logger)] 

618 loggers = [logging.getLogger(), *named] 

619 for logger in loggers: 

620 for handler in logger.handlers: 

621 with suppress(OSError, ValueError): 

622 handler.flush() 

623 for stream in (sys.stdout, sys.stderr): 

624 with suppress(AttributeError, OSError, ValueError): 

625 stream.flush() 

626 

627 

628def _exit_now(status: int, note: str) -> None: 

629 """Write *note*, flush the logs, then end the process with *status*, skipping atexit.""" 

630 _write_exit_note(note) 

631 _flush_logs() 

632 os._exit(status) 

633 

634 

635def _all_threads() -> list[threading.Thread]: 

636 """Seam over threading.enumerate, so tests never patch the stdlib global.""" 

637 return threading.enumerate() 

638 

639 

640def wait_for_hard_exit_teardown() -> None: 

641 """Block until any teardown thread (signal-driven or exit-driven) finishes. 

642 

643 Lets ``serve`` hold its OS locks through the fleet stop, so a successor 

644 cannot acquire them while this server's models still occupy memory. 

645 

646 An interrupt while waiting force-quits the process: waiting is the only 

647 thing left, the interpreter would otherwise still join the non-daemon 

648 teardown thread, and the engine reaper (or, without kernel file locks, the 

649 child guard) stops the engine once this was its last user. 

650 """ 

651 try: 

652 for thread in _all_threads(): 

653 if thread.name != _HARD_EXIT_THREAD_NAME: 

654 continue 

655 thread.join(_STOPPING_NOTE_GRACE_S) 

656 if thread.is_alive(): 

657 _write_exit_note(_STOPPING_NOTE) 

658 thread.join() 

659 except KeyboardInterrupt: 

660 _exit_now(_FORCE_QUIT_EXIT_CODE, _FORCE_QUIT_NOTE) 

661 

662 

663def reset_services_on_exit() -> None: 

664 """Tear the container down on a thread no signal reaches, and wait for it. 

665 

666 Engine release waits on the fleet build lock before it releases anything, so 

667 a Ctrl-C on the main thread skips the release, and atexit cannot retry: the 

668 singleton is already cleared. The teardown thread is non-daemon and takes 

669 no signals; a wait that outlives the grace names what is happening. 

670 """ 

671 if peek_services() is None: 

672 return 

673 threading.Thread(target=reset_services, name=_HARD_EXIT_THREAD_NAME).start() 

674 wait_for_hard_exit_teardown() 

675 

676 

677def _straggler_threads() -> list[threading.Thread]: 

678 """Non-daemon threads the interpreter would join at shutdown, besides us.""" 

679 return [ 

680 thread 

681 for thread in _all_threads() 

682 if thread.is_alive() 

683 and not thread.daemon 

684 and thread is not threading.main_thread() 

685 and thread is not threading.current_thread() 

686 ] 

687 

688 

689def exit_when_stragglers_would_hang() -> None: 

690 """Exit rather than let interpreter shutdown join a wedged thread forever. 

691 

692 threading joins every non-daemon thread after atexit, so one worker 

693 blocked in an unbounded network read hangs the process after all real 

694 work is done. Stragglers share a short budget; whatever remains cannot 

695 finish, and the child guard reaps the engine when the process dies. 

696 Called from the TUI's own exit path and inert elsewhere: a test process 

697 or embedding host owns its threads, and an exit would take them with it. 

698 """ 

699 if not _state.interactive: 

700 return 

701 _exit_if_stragglers(0) 

702 

703 

704def _exit_if_stragglers(status: int) -> None: 

705 """Join leftover non-daemon threads within the budget, else exit with *status*.""" 

706 try: 

707 deadline = time.monotonic() + _STRAGGLER_BUDGET_S 

708 for thread in _straggler_threads(): 

709 thread.join(max(0.0, deadline - time.monotonic())) 

710 if _straggler_threads(): 

711 _exit_now(status, _STRAGGLER_NOTE) 

712 except KeyboardInterrupt: 

713 _exit_now(_FORCE_QUIT_EXIT_CODE, _FORCE_QUIT_NOTE) 

714 

715 

716def exit_if_opens_remain(status: int) -> None: 

717 """End the process with *status* when a library open still runs; else return.""" 

718 # A daemon thread inside LanceDB at finalization aborts Python 3.11 and 3.12 

719 # (CPython gh-87135). The exit skips atexit, so the teardown runs first. 

720 if wait_for_opens(0.0): 

721 return 

722 reset_services_on_exit() 

723 _exit_now(status, _OPEN_EXIT_NOTE) 

724 

725 

726def exit_on_signals_in_loop(loop: asyncio.AbstractEventLoop) -> None: 

727 """Make Ctrl-C and terminate signals end a process whose loop has no UI to catch them.""" 

728 _lifecycle.route_in_loop(loop) 

729 

730 

731def exit_after_signal(status: int) -> None: 

732 """Finish the signal-driven teardown, then exit with *status* even past a wedged thread. 

733 

734 The stdio MCP server reads stdin on a non-daemon thread that no signal 

735 wakes, so the interpreter would wait on it forever after the main thread 

736 unwound. 

737 """ 

738 reset_services_on_exit() 

739 _exit_if_stragglers(status) 

740 

741 

742def _teardown_for_signal(signum: int) -> None: 

743 """Log the fatal signal, then stop services; runs off the signal handler's thread.""" 

744 log.info( 

745 "Received signal %s; stopping the engine fleet before exit", signal.Signals(signum).name 

746 ) 

747 reset_services() 

748 

749 

750_lifecycle = _EngineLifecycle() 

751 

752 

753def install_engine_lifecycle_hooks() -> None: 

754 """Make a terminal close or ``kill`` stop the engine fleet instead of orphaning it.""" 

755 _lifecycle.install() 

756 

757 

758atexit.register(reset_services_on_exit)