Files
Jiangzhou 9fd2e7470a fix(search): surface daemon-side tracebacks and reject NULL KNN distances (#271)
Issue #270 reported `ccc search --path` crashing with `TypeError:
unsupported operand type(s) for *: 'NoneType' and 'NoneType'`. The crash
could not be reproduced, and the reported root cause does not hold:
`vec_distance_L2` never returns NULL, it raises (verified against
sqlite-vec 0.1.6-0.1.9, SQLite 3.46/3.53, multi-chunk tables and
re-index churn). What the report did expose is that the failure was
undiagnosable.

Two gaps, both fixed here:

- The daemon's search handler discarded the traceback
  (`ErrorResponse(message=str(e))`), so the reporter saw only the
  client's re-raise frames. It now sends `traceback.format_exc()` and
  logs the exception; `_dispatch` does the same. On the client, the
  `raise RuntimeError(f"Daemon error: ...")` pattern was duplicated at
  five sites and only `doctor()` appended `resp.traceback` -- the search
  path, which the reporter came through, dropped it. Consolidated into
  one `_daemon_error()` helper used by all five.

- `_knn_query` now rejects rows with a NULL distance, raising a specific
  error naming the query shape and offending file instead of dying in
  `_l2_to_score`. `distance` is a hidden vec0 column that sqlite-vec
  populates only under the KNN query plan and returns NULL for on a full
  scan, so a NULL means the plan we asked for is not the plan we got.
  The guard lives in `_knn_query` rather than the caller because the
  multi-language merge path sorts on `r[5]` in `heapq.nsmallest`, where
  a NULL would fail on `None < float` before any caller-side check ran.
  `_full_scan_query` needs no guard: it computes `vec_distance_L2(...)`
  itself, which works under any plan and raises rather than returning
  NULL on bad input.

The new tests build a real in-memory vec0 table with the indexer's exact
DDL (no embedding model, ~0.2s) and cover both that `--path` filtering
yields usable distances and that the bare-`distance`-under-full-scan
shape is the one that does not.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 16:10:51 -07:00

409 lines
16 KiB
Python

"""Tests for client connection handling."""
from __future__ import annotations
import tempfile
from multiprocessing.connection import Connection
from pathlib import Path
from typing import cast
import pytest
from cocoindex_code import client
from cocoindex_code._daemon_paths import LastExitMarker
from cocoindex_code.protocol import HandshakeResponse
def test_client_connect_refuses_when_no_daemon(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
sock_dir = Path(tempfile.mkdtemp(prefix="ccc_noconn_"))
sock_path = str(sock_dir / "d.sock")
monkeypatch.setattr("cocoindex_code.client.daemon_socket_path", lambda: sock_path)
with pytest.raises(ConnectionRefusedError):
client._raw_connect_and_handshake()
def test_is_daemon_supervised_reads_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
"""The supervised branch is controlled by COCOINDEX_CODE_DAEMON_SUPERVISED=1."""
monkeypatch.delenv("COCOINDEX_CODE_DAEMON_SUPERVISED", raising=False)
assert client._is_daemon_supervised() is False
monkeypatch.setenv("COCOINDEX_CODE_DAEMON_SUPERVISED", "1")
assert client._is_daemon_supervised() is True
# Anything other than exact "1" is not supervised (avoid accidental truthy values).
monkeypatch.setenv("COCOINDEX_CODE_DAEMON_SUPERVISED", "true")
assert client._is_daemon_supervised() is False
monkeypatch.setenv("COCOINDEX_CODE_DAEMON_SUPERVISED", "0")
assert client._is_daemon_supervised() is False
def test_print_handshake_warnings_dedupes_within_process(
capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Each distinct handshake warning is surfaced at most once per process."""
from cocoindex_code.protocol import HandshakeResponse
monkeypatch.setattr(client, "_surfaced_warnings", set())
resp1 = HandshakeResponse(
ok=True, daemon_version="x", pid=1, warnings=["first warning", "second warning"]
)
resp2 = HandshakeResponse(
ok=True, daemon_version="x", pid=1, warnings=["first warning", "third warning"]
)
client._print_handshake_warnings(resp1)
client._print_handshake_warnings(resp2)
err = capsys.readouterr().err
assert err.count("first warning") == 1
assert err.count("second warning") == 1
assert err.count("third warning") == 1
# Every line is rendered through the shared util and gets the "Warning:" prefix.
assert err.count("Warning:") == 3
def test_print_warning_prefixes_message(capsys: pytest.CaptureFixture[str]) -> None:
client.print_warning("something happened")
err = capsys.readouterr().err
assert err.startswith("Warning: something happened")
def test_print_handshake_warnings_no_warnings_prints_nothing(
capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
) -> None:
from cocoindex_code.protocol import HandshakeResponse
monkeypatch.setattr(client, "_surfaced_warnings", set())
client._print_handshake_warnings(HandshakeResponse(ok=True, daemon_version="x", pid=1))
assert capsys.readouterr().err == ""
def test_connect_restarts_ensured_daemon_on_stale_settings(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An already-ensured daemon reporting stale global settings (resp.ok True,
moved mtime) is restarted, not surfaced as an error. This is the `ccc init`
retry path, where rewriting global_settings.yml changes its mtime.
"""
from cocoindex_code.protocol import HandshakeResponse
monkeypatch.setattr(client, "_daemon_ensured", True)
sentinel_conn = object()
ok_resp = HandshakeResponse(ok=True, daemon_version="v1", pid=42)
calls = {"raw": 0, "stop": 0, "start": 0}
def fake_raw() -> client._HandshakeResult:
calls["raw"] += 1
if calls["raw"] == 1:
raise client.DaemonVersionError(
HandshakeResponse(ok=True, daemon_version="v1", pid=42, global_settings_mtime_us=1)
)
return client._HandshakeResult(conn=cast(Connection, sentinel_conn), resp=ok_resp)
monkeypatch.setattr(client, "_raw_connect_and_handshake", fake_raw)
monkeypatch.setattr(client, "stop_daemon", lambda: calls.update(stop=calls["stop"] + 1))
monkeypatch.setattr(client, "start_daemon", lambda: calls.update(start=calls["start"] + 1))
monkeypatch.setattr(client, "_wait_for_daemon", lambda **_kw: None)
monkeypatch.setattr(client, "_is_daemon_supervised", lambda: False)
conn = client._connect_and_handshake()
assert conn is sentinel_conn
assert calls["stop"] == 1 # old daemon stopped
assert calls["start"] == 1 # fresh daemon started to reload settings
assert calls["raw"] == 2 # reconnected after restart
def test_connect_fails_fast_on_version_mismatch_after_ensured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A genuine version mismatch (resp.ok False) after the daemon was already
ensured means the binary was swapped under us — fail fast, don't restart.
"""
from cocoindex_code.protocol import HandshakeResponse
monkeypatch.setattr(client, "_daemon_ensured", True)
started = {"start": 0}
def fake_raw() -> object:
raise client.DaemonVersionError(
HandshakeResponse(ok=False, daemon_version="other-version", pid=42)
)
monkeypatch.setattr(client, "_raw_connect_and_handshake", fake_raw)
monkeypatch.setattr(client, "stop_daemon", lambda: None)
monkeypatch.setattr(client, "start_daemon", lambda: started.update(start=1))
monkeypatch.setattr(client, "_wait_for_daemon", lambda **_kw: None)
monkeypatch.setattr(client, "_is_daemon_supervised", lambda: False)
with pytest.raises(client.DaemonVersionError):
client._connect_and_handshake()
assert started["start"] == 0 # never tried to restart
def test_connect_restarts_daemon_on_undecodable_handshake(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A handshake reply that does not even decode — a daemon whose wire
protocol drifted beyond the version-mismatch path, e.g. a stale
pre-upgrade daemon (issue #237) — is restarted, not surfaced as a raw
decode error.
"""
monkeypatch.setattr(client, "_daemon_ensured", False)
sentinel_conn = object()
ok_resp = HandshakeResponse(ok=True, daemon_version="v1", pid=42)
calls = {"raw": 0, "stop": 0, "start": 0}
def fake_raw() -> client._HandshakeResult:
calls["raw"] += 1
if calls["raw"] == 1:
raise client.DaemonProtocolError("Undecodable handshake reply from daemon")
return client._HandshakeResult(conn=cast(Connection, sentinel_conn), resp=ok_resp)
monkeypatch.setattr(client, "_raw_connect_and_handshake", fake_raw)
monkeypatch.setattr(client, "stop_daemon", lambda: calls.update(stop=calls["stop"] + 1))
monkeypatch.setattr(client, "start_daemon", lambda: calls.update(start=calls["start"] + 1))
monkeypatch.setattr(client, "_wait_for_daemon", lambda **_kw: None)
monkeypatch.setattr(client, "_is_daemon_supervised", lambda: False)
conn = client._connect_and_handshake()
assert conn is sentinel_conn
assert calls["stop"] == 1 # incompatible daemon stopped
assert calls["start"] == 1 # fresh daemon started
assert calls["raw"] == 2 # reconnected after restart
def test_connect_fails_fast_on_undecodable_handshake_after_ensured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Once a matching daemon was ensured, an undecodable handshake reply
cannot legitimately happen — fail fast, don't loop on restarts.
"""
monkeypatch.setattr(client, "_daemon_ensured", True)
started = {"start": 0}
def fake_raw() -> client._HandshakeResult:
raise client.DaemonProtocolError("Undecodable handshake reply from daemon")
monkeypatch.setattr(client, "_raw_connect_and_handshake", fake_raw)
monkeypatch.setattr(client, "stop_daemon", lambda: None)
monkeypatch.setattr(client, "start_daemon", lambda: started.update(start=1))
monkeypatch.setattr(client, "_wait_for_daemon", lambda **_kw: None)
monkeypatch.setattr(client, "_is_daemon_supervised", lambda: False)
with pytest.raises(client.DaemonProtocolError):
client._connect_and_handshake()
assert started["start"] == 0 # never tried to restart
def test_stop_daemon_escalates_past_undecodable_handshake(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""``ccc daemon stop`` must still work against a protocol-incompatible
daemon (issue #237): the graceful StopRequest is impossible, so it falls
through to the kill escalation instead of crashing.
"""
monkeypatch.setattr(client, "daemon_pid_path", lambda: tmp_path / "daemon.pid")
def fake_raw() -> client._HandshakeResult:
raise client.DaemonProtocolError("Undecodable handshake reply from daemon")
monkeypatch.setattr(client, "_raw_connect_and_handshake", fake_raw)
waited = {"n": 0}
def fake_wait(timeout: float) -> bool:
waited["n"] += 1
return True
monkeypatch.setattr(client, "_wait_for_daemon_exit", fake_wait)
client.stop_daemon() # must not raise
assert waited["n"] == 1 # reached the escalation ladder
# ---------------------------------------------------------------------------
# Vanished-daemon handling: graceful-exit marker vs crash
# ---------------------------------------------------------------------------
def _setup_vanished_daemon(
monkeypatch: pytest.MonkeyPatch,
*,
marker: LastExitMarker | None,
ensured_pid: int = 42,
) -> tuple[object, dict[str, int]]:
"""Scaffolding: an ensured daemon whose next connect is refused.
``marker`` is what ``read_last_exit_marker`` returns — a marker with
``pid == ensured_pid`` simulates a graceful exit, anything else a crash.
"""
monkeypatch.setattr(client, "_daemon_ensured", True)
monkeypatch.setattr(client, "_ensured_daemon_pid", ensured_pid)
monkeypatch.setattr(client, "_consecutive_crash_restarts", 0)
monkeypatch.setattr(client, "read_last_exit_marker", lambda: marker)
sentinel_conn = object()
ok_resp = HandshakeResponse(ok=True, daemon_version="v1", pid=43)
calls = {"raw": 0, "start": 0}
def fake_raw() -> client._HandshakeResult:
calls["raw"] += 1
if calls["raw"] == 1:
raise ConnectionRefusedError("daemon socket not found")
return client._HandshakeResult(conn=cast(Connection, sentinel_conn), resp=ok_resp)
monkeypatch.setattr(client, "_raw_connect_and_handshake", fake_raw)
monkeypatch.setattr(client, "start_daemon", lambda: calls.update(start=calls["start"] + 1))
monkeypatch.setattr(client, "_wait_for_daemon", lambda **_kw: None)
monkeypatch.setattr(client, "_is_daemon_supervised", lambda: False)
return sentinel_conn, calls
def test_connect_restarts_silently_after_graceful_exit(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""A graceful exit (marker with the ensured daemon's pid — e.g. a
manual stop) is transparently restarted with no warning.
"""
marker = LastExitMarker(pid=42, reason="stop_request", timestamp=0.0)
sentinel_conn, calls = _setup_vanished_daemon(monkeypatch, marker=marker, ensured_pid=42)
conn = client._connect_and_handshake()
assert conn is sentinel_conn
assert calls["start"] == 1 # fresh daemon started
assert capsys.readouterr().err == "" # ...but silently
assert client._consecutive_crash_restarts == 0
def test_connect_warns_and_restarts_after_crash(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""No marker means the daemon crashed: restart, but loudly."""
sentinel_conn, calls = _setup_vanished_daemon(monkeypatch, marker=None)
conn = client._connect_and_handshake()
assert conn is sentinel_conn
assert calls["start"] == 1
err = capsys.readouterr().err
assert "exited unexpectedly" in err
assert client._consecutive_crash_restarts == 1
def test_connect_marker_pid_mismatch_counts_as_crash(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""A marker from a *different* process is not evidence our daemon exited
gracefully — treat it as a crash.
"""
marker = LastExitMarker(pid=999, reason="stop_request", timestamp=0.0)
sentinel_conn, calls = _setup_vanished_daemon(monkeypatch, marker=marker, ensured_pid=42)
conn = client._connect_and_handshake()
assert conn is sentinel_conn
assert calls["start"] == 1
assert "exited unexpectedly" in capsys.readouterr().err
assert client._consecutive_crash_restarts == 1
def test_connect_gives_up_after_consecutive_crashes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The third consecutive crash raises instead of relaunching a
crash-looping daemon.
"""
_sentinel_conn, calls = _setup_vanished_daemon(monkeypatch, marker=None)
monkeypatch.setattr(client, "_consecutive_crash_restarts", 2) # two prior crashes
with pytest.raises(RuntimeError, match="crashed 3 times in a row"):
client._connect_and_handshake()
assert calls["start"] == 0 # never tried to restart
def test_crash_counter_resets_on_clean_connect(monkeypatch: pytest.MonkeyPatch) -> None:
"""A daemon that answers without needing a restart ends the crash streak,
and its pid is remembered for future marker matching.
"""
monkeypatch.setattr(client, "_daemon_ensured", True)
monkeypatch.setattr(client, "_ensured_daemon_pid", 42)
monkeypatch.setattr(client, "_consecutive_crash_restarts", 2)
sentinel_conn = object()
ok_resp = HandshakeResponse(ok=True, daemon_version="v1", pid=7)
monkeypatch.setattr(
client,
"_raw_connect_and_handshake",
lambda: client._HandshakeResult(conn=cast(Connection, sentinel_conn), resp=ok_resp),
)
conn = client._connect_and_handshake()
assert conn is sentinel_conn
assert client._consecutive_crash_restarts == 0
assert client._ensured_daemon_pid == 7
def test_last_exit_marker_round_trip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from cocoindex_code._daemon_paths import (
clear_last_exit_marker,
read_last_exit_marker,
write_last_exit_marker,
)
monkeypatch.setenv("COCOINDEX_CODE_RUNTIME_DIR", str(tmp_path))
assert read_last_exit_marker() is None
write_last_exit_marker(pid=123, reason="stop_request")
marker = read_last_exit_marker()
assert marker is not None
assert marker.pid == 123
assert marker.reason == "stop_request"
assert marker.timestamp > 0
clear_last_exit_marker()
assert read_last_exit_marker() is None
def test_daemon_version_error_message_reflects_cause() -> None:
"""The error text matches the real cause — not always "version mismatch"."""
from cocoindex_code.protocol import HandshakeResponse
version_err = client.DaemonVersionError(HandshakeResponse(ok=False, daemon_version="x", pid=1))
assert "version mismatch" in str(version_err)
settings_err = client.DaemonVersionError(
HandshakeResponse(ok=True, daemon_version="x", pid=1, global_settings_mtime_us=1)
)
assert "stale global settings" in str(settings_err)
assert "version mismatch" not in str(settings_err)
def test_daemon_error_carries_daemon_side_traceback() -> None:
"""Daemon-side tracebacks reach the caller — the client frames alone say nothing.
Regression guard for issue #270, where a daemon search crash surfaced as a
bare `Daemon error: <message>` and the reporter had no frame pointing at the
code that actually failed.
"""
from cocoindex_code.protocol import ErrorResponse
err = client._daemon_error(ErrorResponse(message="boom", traceback="Traceback: frame\nfoo"))
assert "Daemon error: boom" in str(err)
assert "Traceback: frame\nfoo" in str(err)
# No traceback recorded (e.g. a deliberate ErrorResponse, not an exception):
# the message stands alone, with no trailing noise.
plain = client._daemon_error(ErrorResponse(message="run `ccc init` first"))
assert str(plain) == "Daemon error: run `ccc init` first"