diff --git a/src/cocoindex_code/cli.py b/src/cocoindex_code/cli.py index 3cb973e..dde76a0 100644 --- a/src/cocoindex_code/cli.py +++ b/src/cocoindex_code/cli.py @@ -287,16 +287,29 @@ def daemon_restart() -> None: @daemon_app.command("stop") def daemon_stop() -> None: """Stop the daemon.""" - from .client import DaemonClient + from .client import stop_daemon + from .daemon import daemon_pid_path - try: - client = DaemonClient.connect() - client.handshake() - client.stop() - client.close() - _typer.echo("Daemon stopped.") - except (ConnectionRefusedError, OSError): + pid_path = daemon_pid_path() + if not pid_path.exists(): _typer.echo("Daemon is not running.") + return + + stop_daemon() + + # Wait for process to exit + import time + + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + if not pid_path.exists(): + break + time.sleep(0.1) + + if pid_path.exists(): + _typer.echo("Warning: daemon may not have stopped cleanly.", err=True) + else: + _typer.echo("Daemon stopped.") @app.command("run-daemon", hidden=True) diff --git a/src/cocoindex_code/client.py b/src/cocoindex_code/client.py index f62b05c..e0fdb36 100644 --- a/src/cocoindex_code/client.py +++ b/src/cocoindex_code/client.py @@ -162,26 +162,43 @@ def _find_ccc_executable() -> str | None: def stop_daemon() -> None: - """Stop the daemon gracefully.""" + """Stop the daemon gracefully. + + Sends a StopRequest, waits for the process to exit, falls back to SIGTERM. + """ + # Step 1: try sending StopRequest try: client = DaemonClient.connect() client.handshake() client.stop() client.close() - except (ConnectionRefusedError, OSError): + except (ConnectionRefusedError, OSError, RuntimeError): pass - # If daemon doesn't respond, try SIGTERM via PID + # Step 2: wait for process to exit (up to 5s) pid_path = daemon_pid_path() + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline and pid_path.exists(): + time.sleep(0.1) + + if not pid_path.exists(): + return # Clean exit + + # Step 3: if still running, try SIGTERM if pid_path.exists(): try: pid = int(pid_path.read_text().strip()) - if pid != os.getpid(): # Never kill ourselves (happens when daemon runs in a thread) + if pid != os.getpid(): os.kill(pid, signal.SIGTERM) except (ValueError, ProcessLookupError, PermissionError): pass - # Clean up stale files (named pipes on Windows clean up automatically) + # Wait a bit more + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline and pid_path.exists(): + time.sleep(0.1) + + # Step 4: clean up stale files if sys.platform != "win32": sock = daemon_socket_path() try: diff --git a/src/cocoindex_code/daemon.py b/src/cocoindex_code/daemon.py index 6660452..1fb5f7a 100644 --- a/src/cocoindex_code/daemon.py +++ b/src/cocoindex_code/daemon.py @@ -210,10 +210,18 @@ async def handle_connection( loop = asyncio.get_event_loop() handshake_done = False + def _recv() -> bytes: + """Blocking recv that also checks for shutdown.""" + # Use poll with a timeout so we can check shutdown_event periodically + while not shutdown_event.is_set(): + if conn.poll(0.5): + return conn.recv_bytes() + raise EOFError("shutdown") + try: while not shutdown_event.is_set(): try: - data: bytes = await loop.run_in_executor(None, conn.recv_bytes) + data: bytes = await loop.run_in_executor(None, _recv) except (EOFError, OSError): break diff --git a/src/cocoindex_code/server.py b/src/cocoindex_code/server.py index 5eec11d..b1343a6 100644 --- a/src/cocoindex_code/server.py +++ b/src/cocoindex_code/server.py @@ -188,7 +188,7 @@ def main() -> None: LanguageOverride, default_project_settings, default_user_settings, - find_parent_with_marker, + find_legacy_project_root, find_project_root, project_settings_path, save_project_settings, @@ -216,8 +216,8 @@ def main() -> None: project_root = Path(env_root).resolve() else: # Use marker-based discovery - marker_root = find_parent_with_marker(cwd) - project_root = marker_root if marker_root is not None else cwd + legacy_root = find_legacy_project_root(cwd) + project_root = legacy_root if legacy_root is not None else cwd # --- Auto-create project settings if needed --- proj_settings_file = project_settings_path(project_root) diff --git a/src/cocoindex_code/settings.py b/src/cocoindex_code/settings.py index 97f356a..fb5b8be 100644 --- a/src/cocoindex_code/settings.py +++ b/src/cocoindex_code/settings.py @@ -151,6 +151,22 @@ def find_project_root(start: Path) -> Path | None: current = parent +def find_legacy_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a ``.cocoindex_code/`` dir that contains ``cocoindex.db``. + + Used by the backward-compat ``cocoindex-code`` entrypoint to re-anchor to a + previously-indexed project tree. Returns the first matching directory, or ``None``. + """ + current = start.resolve() + while True: + if (current / _SETTINGS_DIR_NAME / "cocoindex.db").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + def find_parent_with_marker(start: Path) -> Path | None: """Walk up from *start* looking for ``.cocoindex_code/`` or ``.git/``. diff --git a/tests/test_backward_compat.py b/tests/test_backward_compat.py index 96b4d67..09bb577 100644 --- a/tests/test_backward_compat.py +++ b/tests/test_backward_compat.py @@ -13,6 +13,7 @@ UserSettings, default_project_settings, default_user_settings, + find_legacy_project_root, load_project_settings, load_user_settings, save_project_settings, @@ -104,6 +105,27 @@ def test_legacy_extra_extensions_conversion(tmp_path: Path) -> None: assert "**/*.toml" in loaded.include_patterns +def test_legacy_root_discovery_requires_cocoindex_db(tmp_path: Path) -> None: + """A .cocoindex_code dir without cocoindex.db should not be matched.""" + (tmp_path / ".cocoindex_code").mkdir() + assert find_legacy_project_root(tmp_path) is None + + +def test_legacy_root_discovery_with_cocoindex_db(tmp_path: Path) -> None: + """A .cocoindex_code dir with cocoindex.db should be matched, including from a subdirectory.""" + idx_dir = tmp_path / ".cocoindex_code" + idx_dir.mkdir() + (idx_dir / "cocoindex.db").touch() + + # Exact directory + assert find_legacy_project_root(tmp_path) == tmp_path + + # From a subdirectory — should walk up and find the root + sub = tmp_path / "src" / "pkg" + sub.mkdir(parents=True) + assert find_legacy_project_root(sub) == tmp_path + + def test_legacy_excluded_patterns_conversion(tmp_path: Path) -> None: """COCOINDEX_CODE_EXCLUDED_PATTERNS should be appended to default exclude_patterns."""