Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions src/cocoindex_code/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand Down
17 changes: 12 additions & 5 deletions src/cocoindex_code/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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)

Expand Down
1 change: 1 addition & 0 deletions src/cocoindex_code/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
13 changes: 13 additions & 0 deletions src/cocoindex_code/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading