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
29 changes: 21 additions & 8 deletions src/cocoindex_code/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 22 additions & 5 deletions src/cocoindex_code/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 9 additions & 1 deletion src/cocoindex_code/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions src/cocoindex_code/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions src/cocoindex_code/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/``.

Expand Down
22 changes: 22 additions & 0 deletions tests/test_backward_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
UserSettings,
default_project_settings,
default_user_settings,
find_legacy_project_root,
load_project_settings,
load_user_settings,
save_project_settings,
Expand Down Expand Up @@ -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."""

Expand Down
Loading