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
44 changes: 40 additions & 4 deletions src/cocoindex_code/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,15 @@ def _send(self, req: Request) -> Response:

def is_daemon_running() -> bool:
"""Check if the daemon is running."""
if sys.platform == "win32":
# os.path.exists is unreliable for Windows named pipes;
# try connecting instead.
try:
conn = Client(daemon_socket_path(), family=_connection_family())
conn.close()
return True
except (ConnectionRefusedError, OSError):
return False
return os.path.exists(daemon_socket_path())


Expand Down Expand Up @@ -220,11 +229,14 @@ def stop_daemon() -> None:
return # Clean exit

# Step 3: if still running, try SIGTERM
pid: int | None = None
if pid_path.exists():
try:
pid = int(pid_path.read_text().strip())
if pid != os.getpid():
os.kill(pid, signal.SIGTERM)
else:
pid = None
except (ValueError, ProcessLookupError, PermissionError):
pass

Expand All @@ -240,9 +252,22 @@ def stop_daemon() -> None:
pid = int(pid_path.read_text().strip())
if pid != os.getpid():
os.kill(pid, signal.SIGKILL)
else:
pid = None
except (ValueError, ProcessLookupError, PermissionError):
pass

# Step 4b: on Windows, wait for the process to fully exit after TerminateProcess
# so that named pipe handles are released before starting a new daemon.
if sys.platform == "win32" and pid is not None:
deadline = time.monotonic() + 3.0
while time.monotonic() < deadline:
try:
os.kill(pid, 0) # Check if process still exists
time.sleep(0.1)
except (ProcessLookupError, PermissionError, OSError):
break # Process has exited

# Step 5: clean up stale files
if sys.platform != "win32":
sock = daemon_socket_path()
Expand All @@ -256,13 +281,24 @@ def stop_daemon() -> None:
pass


def _wait_for_daemon(timeout: float = 10.0) -> None:
def _wait_for_daemon(timeout: float = 30.0) -> None:
"""Wait for the daemon socket/pipe to become available."""
deadline = time.monotonic() + timeout
sock_path = daemon_socket_path()
while time.monotonic() < deadline:
if os.path.exists(daemon_socket_path()):
return
time.sleep(0.1)
if sys.platform == "win32":
# os.path.exists is unreliable for Windows named pipes;
# try an actual connection to verify the daemon is listening.
try:
conn = Client(sock_path, family=_connection_family())
conn.close()
return
except (ConnectionRefusedError, OSError):
pass
else:
if os.path.exists(sock_path):
return
time.sleep(0.2)
raise TimeoutError("Daemon did not start in time")


Expand Down
50 changes: 30 additions & 20 deletions src/cocoindex_code/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,14 @@

@dataclass
class EmbeddingSettings:
provider: str = "sentence-transformers"
model: str = "sentence-transformers/all-MiniLM-L6-v2"
model: str
provider: str = "litellm"
device: str | None = None


@dataclass
class UserSettings:
embedding: EmbeddingSettings = field(default_factory=EmbeddingSettings)
embedding: EmbeddingSettings
envs: dict[str, str] = field(default_factory=dict)


Expand All @@ -99,7 +99,12 @@ class ProjectSettings:


def default_user_settings() -> UserSettings:
return UserSettings()
return UserSettings(
embedding=EmbeddingSettings(
provider="sentence-transformers",
model="sentence-transformers/all-MiniLM-L6-v2",
)
)


def default_project_settings() -> ProjectSettings:
Expand Down Expand Up @@ -211,27 +216,29 @@ def load_gitignore_spec(project_root: Path) -> GitIgnoreSpec | None:

def _user_settings_to_dict(settings: UserSettings) -> dict[str, Any]:
d: dict[str, Any] = {}
emb: dict[str, Any] = {}
if settings.embedding.provider != "sentence-transformers":
emb["provider"] = settings.embedding.provider
if settings.embedding.model != "sentence-transformers/all-MiniLM-L6-v2":
emb["model"] = settings.embedding.model
emb: dict[str, Any] = {
"provider": settings.embedding.provider,
"model": settings.embedding.model,
}
if settings.embedding.device is not None:
emb["device"] = settings.embedding.device
if emb:
d["embedding"] = emb
d["embedding"] = emb
if settings.envs:
d["envs"] = dict(settings.envs)
return d


def _user_settings_from_dict(d: dict[str, Any]) -> UserSettings:
emb_dict = d.get("embedding", {})
embedding = EmbeddingSettings(
provider=emb_dict.get("provider", "sentence-transformers"),
model=emb_dict.get("model", "sentence-transformers/all-MiniLM-L6-v2"),
device=emb_dict.get("device"),
)
emb_dict = d.get("embedding")
if not emb_dict or "model" not in emb_dict:
raise ValueError("global_settings.yml must contain 'embedding' with at least 'model' field")
# Only pass keys that are present; provider uses dataclass default ("litellm") if omitted
emb_kwargs: dict[str, Any] = {"model": emb_dict["model"]}
if "provider" in emb_dict:
emb_kwargs["provider"] = emb_dict["provider"]
if "device" in emb_dict:
emb_kwargs["device"] = emb_dict["device"]
embedding = EmbeddingSettings(**emb_kwargs)
envs = d.get("envs", {})
return UserSettings(embedding=embedding, envs=envs)

Expand Down Expand Up @@ -265,14 +272,17 @@ def _project_settings_from_dict(d: dict[str, Any]) -> ProjectSettings:


def load_user_settings() -> UserSettings:
"""Read ``~/.cocoindex_code/settings.yml``, return defaults if missing."""
"""Read ``~/.cocoindex_code/global_settings.yml``.

Raises ``FileNotFoundError`` if missing, ``ValueError`` if incomplete.
"""
path = user_settings_path()
if not path.is_file():
return default_user_settings()
raise FileNotFoundError(f"User settings not found: {path}")
with open(path) as f:
data = _yaml.safe_load(f)
if not data:
return default_user_settings()
raise ValueError(f"User settings file is empty: {path}")
return _user_settings_from_dict(data)


Expand Down
5 changes: 3 additions & 2 deletions tests/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,12 @@ def calculate_fibonacci(n: int) -> int:
def daemon_sock() -> Iterator[str]:
"""Start a daemon once per session and return the socket path."""
import cocoindex_code.daemon as dm
from cocoindex_code.settings import EmbeddingSettings
from cocoindex_code.shared import create_embedder
from cocoindex_code.shared import embedder as shared_emb

emb = shared_emb if shared_emb is not None else create_embedder(EmbeddingSettings())
emb = (
shared_emb if shared_emb is not None else create_embedder(default_user_settings().embedding)
)

# Use a short path to stay within AF_UNIX limit
user_dir = Path(tempfile.mkdtemp(prefix="ccc_d_"))
Expand Down
47 changes: 41 additions & 6 deletions tests/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,47 @@ def test_save_and_load_project_settings(tmp_path: Path) -> None:


@pytest.mark.usefixtures("_patch_user_dir")
def test_load_user_settings_missing_file_returns_defaults() -> None:
loaded = load_user_settings()
expected = default_user_settings()
assert loaded.embedding.provider == expected.embedding.provider
assert loaded.embedding.model == expected.embedding.model
assert loaded.envs == expected.envs
def test_load_user_settings_missing_file_raises() -> None:
with pytest.raises(FileNotFoundError):
load_user_settings()


@pytest.mark.usefixtures("_patch_user_dir")
def test_load_user_settings_empty_file_raises(tmp_path: Path) -> None:
path = tmp_path / ".cocoindex_code" / "global_settings.yml"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("{}\n")
with pytest.raises(ValueError):
load_user_settings()


@pytest.mark.usefixtures("_patch_user_dir")
def test_load_user_settings_missing_model_raises(tmp_path: Path) -> None:
path = tmp_path / ".cocoindex_code" / "global_settings.yml"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("embedding:\n provider: litellm\n")
with pytest.raises(ValueError):
load_user_settings()


@pytest.mark.usefixtures("_patch_user_dir")
def test_from_dict_missing_provider_defaults_to_litellm() -> None:
from cocoindex_code.settings import _user_settings_from_dict

settings = _user_settings_from_dict({"embedding": {"model": "some/model"}})
assert settings.embedding.provider == "litellm"
assert settings.embedding.model == "some/model"


@pytest.mark.usefixtures("_patch_user_dir")
def test_save_default_settings_writes_explicit_embedding() -> None:
from cocoindex_code.settings import user_settings_path

save_user_settings(default_user_settings())
content = user_settings_path().read_text()
assert "provider:" in content
assert "model:" in content
assert "sentence-transformers" in content


def test_load_project_settings_missing_file_raises(tmp_path: Path) -> None:
Expand Down
Loading