Skip to content

Commit 70c84f2

Browse files
authored
fix: make sure daemon restarted on version mismatch (#107)
1 parent dd5f7b6 commit 70c84f2

4 files changed

Lines changed: 66 additions & 74 deletions

File tree

src/cocoindex_code/cli.py

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -48,22 +48,6 @@ def require_project_root() -> Path:
4848
return root
4949

5050

51-
def require_daemon_for_project() -> str:
52-
"""Resolve project root, then ensure daemon is running (auto-starting if needed).
53-
54-
Returns ``project_root_str``. Exits on failure.
55-
"""
56-
from .client import ensure_daemon
57-
58-
project_root = require_project_root()
59-
try:
60-
ensure_daemon()
61-
except Exception as e:
62-
_typer.echo(f"Error: Failed to connect to daemon: {e}", err=True)
63-
raise _typer.Exit(code=1)
64-
return str(project_root)
65-
66-
6751
def resolve_default_path(project_root: Path) -> str | None:
6852
"""Compute default ``--path`` filter from CWD relative to project root."""
6953
cwd = Path.cwd().resolve()
@@ -306,7 +290,7 @@ def index() -> None:
306290
"""Create/update index for the codebase."""
307291
from . import client as _client
308292

309-
project_root = require_daemon_for_project()
293+
project_root = str(require_project_root())
310294
print_project_header(project_root)
311295
_run_index_with_progress(project_root)
312296
print_index_stats(_client.project_status(project_root))
@@ -322,7 +306,7 @@ def search(
322306
refresh: bool = _typer.Option(False, "--refresh", help="Refresh index before searching"),
323307
) -> None:
324308
"""Semantic search across the codebase."""
325-
project_root = require_daemon_for_project()
309+
project_root = str(require_project_root())
326310
query_str = " ".join(query)
327311

328312
if refresh:
@@ -353,7 +337,7 @@ def status() -> None:
353337
"""Show project status."""
354338
from . import client as _client
355339

356-
project_root = require_daemon_for_project()
340+
project_root = str(require_project_root())
357341
print_project_header(project_root)
358342
print_index_stats(_client.project_status(project_root))
359343

@@ -436,7 +420,7 @@ def mcp() -> None:
436420
"""Run as MCP server (stdio mode)."""
437421
import asyncio
438422

439-
project_root = require_daemon_for_project()
423+
project_root = str(require_project_root())
440424

441425
async def _run_mcp() -> None:
442426
from .server import create_mcp_server
@@ -469,12 +453,6 @@ def daemon_status() -> None:
469453
"""Show daemon status."""
470454
from . import client as _client
471455

472-
try:
473-
_client.ensure_daemon()
474-
except Exception as e:
475-
_typer.echo(f"Error: {e}", err=True)
476-
raise _typer.Exit(code=1)
477-
478456
resp = _client.daemon_status()
479457
_typer.echo(f"Daemon version: {resp.version}")
480458
_typer.echo(f"Uptime: {resp.uptime_seconds:.1f}s")

src/cocoindex_code/client.py

Lines changed: 61 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,51 @@
5151
# ---------------------------------------------------------------------------
5252

5353

54+
_daemon_ensured = False
55+
56+
5457
def _connect_and_handshake() -> Connection:
5558
"""Connect to the daemon and perform the version handshake.
5659
5760
Returns the open connection for the caller to send exactly one request.
58-
Raises ``ConnectionRefusedError`` if the daemon is not running, or
59-
``RuntimeError`` on protocol/version errors.
61+
62+
On the first call, automatically starts or
63+
restarts the daemon if needed. Subsequent calls fail fast with
64+
``DaemonVersionError`` on mismatch (indicating the daemon was replaced
65+
mid-session, e.g. after a tool upgrade).
6066
"""
67+
global _daemon_ensured # noqa: PLW0603
68+
69+
if _daemon_ensured:
70+
return _raw_connect_and_handshake()
71+
72+
# First connection — auto-start/restart as needed.
73+
try:
74+
conn = _raw_connect_and_handshake()
75+
_daemon_ensured = True
76+
return conn
77+
except DaemonVersionError:
78+
stop_daemon()
79+
except (ConnectionRefusedError, OSError):
80+
pass
81+
82+
start_daemon()
83+
_wait_for_daemon()
84+
85+
# Verify the fresh daemon is reachable
86+
for _attempt in range(10):
87+
try:
88+
conn = _raw_connect_and_handshake()
89+
_daemon_ensured = True
90+
return conn
91+
except (ConnectionRefusedError, OSError):
92+
time.sleep(0.5)
93+
94+
raise RuntimeError("Failed to connect to daemon after starting it")
95+
96+
97+
def _raw_connect_and_handshake() -> Connection:
98+
"""Low-level connect + handshake without auto-start logic."""
6199
sock = daemon_socket_path()
62100
if sys.platform != "win32" and not os.path.exists(sock):
63101
raise ConnectionRefusedError(f"Daemon socket not found: {sock}")
@@ -80,20 +118,25 @@ def _connect_and_handshake() -> Connection:
80118
if not isinstance(resp, HandshakeResponse):
81119
conn.close()
82120
raise RuntimeError(f"Unexpected handshake response: {type(resp).__name__}")
83-
if not resp.ok:
121+
if not resp.ok or _needs_restart(resp):
84122
conn.close()
85-
raise _VersionMismatchError(resp)
123+
raise DaemonVersionError(resp)
86124
return conn
87125

88126

89-
class _VersionMismatchError(Exception):
90-
"""Raised when the daemon version or settings are stale."""
127+
class DaemonVersionError(RuntimeError):
128+
"""Raised when the daemon has a version or settings mismatch.
129+
130+
The first ``_connect_and_handshake()`` call handles this by restarting
131+
the daemon. If a mismatch occurs on a subsequent call, it means the
132+
daemon was replaced mid-session (e.g. after a tool upgrade).
133+
"""
91134

92135
def __init__(self, resp: HandshakeResponse) -> None:
93136
self.resp = resp
94137
super().__init__(
95-
f"Daemon version mismatch: {resp.daemon_version} "
96-
f"(settings_mtime={resp.global_settings_mtime_us})"
138+
f"Daemon version mismatch (daemon={resp.daemon_version}, "
139+
f"client={__version__}). Please retry — the daemon may need a restart."
97140
)
98141

99142

@@ -319,6 +362,8 @@ def stop_daemon() -> None:
319362
320363
Escalation: StopRequest → SIGTERM → SIGKILL.
321364
"""
365+
global _daemon_ensured # noqa: PLW0603
366+
_daemon_ensured = False
322367
pid_path = daemon_pid_path()
323368

324369
pid: int | None = None
@@ -329,10 +374,15 @@ def stop_daemon() -> None:
329374
except (FileNotFoundError, ValueError):
330375
pass
331376

332-
# 1) Graceful StopRequest via socket
377+
# 1) Graceful StopRequest via socket (bypass auto-start)
333378
try:
334-
stop()
335-
except (ConnectionRefusedError, OSError, RuntimeError):
379+
conn = _raw_connect_and_handshake()
380+
try:
381+
conn.send_bytes(encode_request(StopRequest()))
382+
conn.recv_bytes()
383+
finally:
384+
conn.close()
385+
except (ConnectionRefusedError, OSError, RuntimeError, DaemonVersionError):
336386
pass
337387

338388
if _wait_for_daemon_exit(timeout=3.0):
@@ -408,37 +458,3 @@ def _needs_restart(resp: HandshakeResponse) -> bool:
408458
if current_mtime != resp.global_settings_mtime_us:
409459
return True
410460
return False
411-
412-
413-
def ensure_daemon() -> None:
414-
"""Ensure daemon is running with correct version. Starts or restarts as needed.
415-
416-
After this returns, per-request functions (``index``, ``search``, etc.)
417-
can be called directly — each opens its own connection.
418-
"""
419-
# Try connecting to existing daemon
420-
try:
421-
conn = _connect_and_handshake()
422-
conn.close()
423-
return # daemon is up and version matches
424-
except _VersionMismatchError:
425-
stop_daemon()
426-
except (ConnectionRefusedError, OSError):
427-
pass
428-
429-
# Start daemon
430-
start_daemon()
431-
_wait_for_daemon()
432-
433-
# Verify with retries
434-
for _attempt in range(10):
435-
try:
436-
conn = _connect_and_handshake()
437-
conn.close()
438-
return
439-
except _VersionMismatchError as e:
440-
raise RuntimeError(f"Daemon mismatch after fresh start: {e}") from e
441-
except (ConnectionRefusedError, OSError):
442-
time.sleep(0.5)
443-
444-
raise RuntimeError("Failed to connect to daemon after starting it")

src/cocoindex_code/server.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,6 @@ def main() -> None:
274274
from . import client as _client
275275
from .protocol import IndexingProgress
276276

277-
_client.ensure_daemon()
278-
279277
if args.command == "index":
280278
import sys
281279

tests/test_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@ def test_client_connect_refuses_when_no_daemon(
1818
monkeypatch.setattr("cocoindex_code.client.daemon_socket_path", lambda: sock_path)
1919

2020
with pytest.raises(ConnectionRefusedError):
21-
client._connect_and_handshake()
21+
client._raw_connect_and_handshake()

0 commit comments

Comments
 (0)