From bded2da6b2a875ba8526304f24f93e4d5d40671a Mon Sep 17 00:00:00 2001 From: Jiangzhou He Date: Mon, 16 Mar 2026 14:20:06 -0700 Subject: [PATCH] feat: automatically restart daemon on global settings change --- src/cocoindex_code/client.py | 30 ++++++++++++++++++++++++------ src/cocoindex_code/daemon.py | 17 ++++++++++++----- src/cocoindex_code/protocol.py | 1 + src/cocoindex_code/settings.py | 13 +++++++++++++ 4 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src/cocoindex_code/client.py b/src/cocoindex_code/client.py index bb6f5d7..e6497f9 100644 --- a/src/cocoindex_code/client.py +++ b/src/cocoindex_code/client.py @@ -302,20 +302,37 @@ def _wait_for_daemon(timeout: float = 30.0) -> None: raise TimeoutError("Daemon did not start in time") +def _needs_restart(resp: HandshakeResponse) -> bool: + """Check if the daemon needs to be restarted. + + Returns True if the version mismatches or if global_settings.yml has been + modified since the daemon loaded it. + """ + if not resp.ok: + return True + from .settings import global_settings_mtime_us + + current_mtime = global_settings_mtime_us() + if current_mtime != resp.global_settings_mtime_us: + return True + return False + + def ensure_daemon() -> DaemonClient: """Connect to daemon, starting or restarting as needed. 1. Try to connect to existing daemon. 2. If connection refused: start daemon, retry connect with backoff. - 3. If connected but version mismatch: stop old daemon, start new one. + 3. If connected but version mismatch or global settings changed: + stop old daemon, start new one. """ # Try connecting to existing daemon try: client = DaemonClient.connect() resp = client.handshake() - if resp.ok: + if not _needs_restart(resp): return client - # Version mismatch — restart + # Version or settings mismatch — restart client.close() stop_daemon() except (ConnectionRefusedError, OSError): @@ -326,14 +343,15 @@ def ensure_daemon() -> DaemonClient: _wait_for_daemon() # Connect with retries - for attempt in range(10): + for _attempt in range(10): try: client = DaemonClient.connect() resp = client.handshake() - if resp.ok: + if not _needs_restart(resp): return client raise RuntimeError( - f"Daemon version mismatch: expected {__version__}, got {resp.daemon_version}" + f"Daemon mismatch after fresh start: version={resp.daemon_version}, " + f"settings_mtime={resp.global_settings_mtime_us}" ) except (ConnectionRefusedError, OSError): time.sleep(0.5) diff --git a/src/cocoindex_code/daemon.py b/src/cocoindex_code/daemon.py index a1a4286..8c2d380 100644 --- a/src/cocoindex_code/daemon.py +++ b/src/cocoindex_code/daemon.py @@ -45,6 +45,7 @@ ) from .query import query_codebase from .settings import ( + global_settings_mtime_us, load_project_settings, load_user_settings, user_settings_dir, @@ -288,6 +289,7 @@ async def handle_connection( registry: ProjectRegistry, start_time: float, shutdown_event: asyncio.Event, + settings_mtime_us: int | None, ) -> None: """Handle a single client connection.""" loop = asyncio.get_event_loop() @@ -322,7 +324,11 @@ def _recv() -> bytes: break ok = req.version == __version__ - resp = HandshakeResponse(ok=ok, daemon_version=__version__) + resp = HandshakeResponse( + ok=ok, + daemon_version=__version__, + global_settings_mtime_us=settings_mtime_us, + ) conn.send_bytes(encode_response(resp)) if not ok: break @@ -419,8 +425,9 @@ def run_daemon() -> None: """Main entry point for the daemon process (blocking).""" daemon_dir().mkdir(parents=True, exist_ok=True) - # Load user settings + # Load user settings and record mtime for staleness detection user_settings = load_user_settings() + settings_mtime_us = global_settings_mtime_us() # Set environment variables from settings for key, value in user_settings.envs.items(): @@ -445,7 +452,7 @@ def run_daemon() -> None: logger.info("Daemon starting (PID %d, version %s)", os.getpid(), __version__) try: - asyncio.run(_async_daemon_main(embedder)) + asyncio.run(_async_daemon_main(embedder, settings_mtime_us)) finally: # Clean up PID file and socket (named pipes on Windows clean up automatically) try: @@ -461,7 +468,7 @@ def run_daemon() -> None: logger.info("Daemon stopped") -async def _async_daemon_main(embedder: Embedder) -> None: +async def _async_daemon_main(embedder: Embedder, settings_mtime_us: int | None) -> None: """Async main loop for the daemon.""" start_time = time.monotonic() registry = ProjectRegistry(embedder) @@ -496,7 +503,7 @@ async def _spawn_handler( evt: asyncio.Event, task_set: set[asyncio.Task[Any]], ) -> None: - task = asyncio.create_task(handle_connection(conn, reg, st, evt)) + task = asyncio.create_task(handle_connection(conn, reg, st, evt, settings_mtime_us)) task_set.add(task) task.add_done_callback(task_set.discard) diff --git a/src/cocoindex_code/protocol.py b/src/cocoindex_code/protocol.py index 8f6d062..25fb496 100644 --- a/src/cocoindex_code/protocol.py +++ b/src/cocoindex_code/protocol.py @@ -61,6 +61,7 @@ class StopRequest(_msgspec.Struct, tag="stop"): class HandshakeResponse(_msgspec.Struct, tag="handshake"): ok: bool daemon_version: str + global_settings_mtime_us: int | None = None class IndexResponse(_msgspec.Struct, tag="index"): diff --git a/src/cocoindex_code/settings.py b/src/cocoindex_code/settings.py index a403dc2..dcb155a 100644 --- a/src/cocoindex_code/settings.py +++ b/src/cocoindex_code/settings.py @@ -195,6 +195,19 @@ def find_parent_with_marker(start: Path) -> Path | None: current = parent +def global_settings_mtime_us() -> int | None: + """Return the mtime of ``global_settings.yml`` as integer microseconds. + + Returns ``None`` if the file does not exist. Used by the daemon to record + the mtime at startup and by the client to detect staleness. + """ + path = user_settings_path() + try: + return int(path.stat().st_mtime * 1_000_000) + except FileNotFoundError: + return None + + def load_gitignore_spec(project_root: Path) -> GitIgnoreSpec | None: """Load a GitIgnoreSpec for the project's ``.gitignore`` if present.""" gitignore = project_root / ".gitignore"