mirror of
https://github.com/cocoindex-io/cocoindex-code.git
synced 2026-09-14 16:39:38 +08:00
fd99851cd5
Fixes #237. Upgrading 0.2.37 → 0.2.38 bricked every ccc command with "ValidationError: Object missing required field `pid`": 0.2.38 added a required `pid` field to HandshakeResponse, so the reply of a still-running pre-upgrade daemon no longer decoded — before the client could see the `ok=False` version mismatch and restart it. The same uncaught error also broke `ccc daemon stop`, the recovery path. Three layers: - `HandshakeResponse.pid` gets a default (None), with a comment stating the wire-compat rule: handshake fields added after a release must have defaults, since the handshake is the one message exchanged between mismatched versions. - An undecodable handshake reply now raises `DaemonProtocolError` instead of escaping as a raw decode error; `_connect_and_handshake` treats it like a version mismatch (restart on first contact, fail fast once a matching daemon was ensured). This protects against any future wire drift, not just this field. - `stop_daemon` tolerates the decode failure and falls through to its SIGTERM/SIGKILL escalation, so `ccc daemon stop` always works. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
364 lines
11 KiB
Python
364 lines
11 KiB
Python
"""Unit tests for the protocol module."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import msgspec
|
|
|
|
from cocoindex_code.protocol import (
|
|
DaemonEnvRequest,
|
|
DaemonEnvResponse,
|
|
DaemonProjectInfo,
|
|
DaemonStatusRequest,
|
|
DaemonStatusResponse,
|
|
DoctorCheckResult,
|
|
DoctorRequest,
|
|
DoctorResponse,
|
|
ErrorResponse,
|
|
HandshakeRequest,
|
|
HandshakeResponse,
|
|
HeartbeatRequest,
|
|
HeartbeatResponse,
|
|
IndexingProgress,
|
|
IndexProgressUpdate,
|
|
IndexRequest,
|
|
IndexResponse,
|
|
IndexWaitingNotice,
|
|
ProjectStatusRequest,
|
|
ProjectStatusResponse,
|
|
RemoveProjectRequest,
|
|
RemoveProjectResponse,
|
|
Request,
|
|
Response,
|
|
SearchRequest,
|
|
SearchResponse,
|
|
SearchResult,
|
|
StopRequest,
|
|
StopResponse,
|
|
decode_request,
|
|
decode_response,
|
|
encode_request,
|
|
encode_response,
|
|
)
|
|
|
|
|
|
def test_encode_decode_handshake_request() -> None:
|
|
req = HandshakeRequest(version="1.0.0")
|
|
data = encode_request(req)
|
|
decoded = decode_request(data)
|
|
assert isinstance(decoded, HandshakeRequest)
|
|
assert decoded.version == "1.0.0"
|
|
|
|
|
|
def test_encode_decode_handshake_response_with_pid() -> None:
|
|
resp = HandshakeResponse(ok=True, daemon_version="1.0.0", pid=4242)
|
|
decoded = decode_response(encode_response(resp))
|
|
assert isinstance(decoded, HandshakeResponse)
|
|
assert decoded.ok is True
|
|
assert decoded.daemon_version == "1.0.0"
|
|
assert decoded.pid == 4242
|
|
|
|
|
|
def test_decode_handshake_response_from_pre_0_2_38_daemon() -> None:
|
|
"""A pre-0.2.38 daemon's handshake reply has no ``pid`` field; a newer
|
|
client must still decode it to reach the version-mismatch restart path
|
|
(issue #237).
|
|
"""
|
|
old_reply = msgspec.msgpack.encode(
|
|
{
|
|
"type": "handshake",
|
|
"ok": False,
|
|
"daemon_version": "0.2.37",
|
|
"global_settings_mtime_us": 1234,
|
|
"warnings": [],
|
|
}
|
|
)
|
|
decoded = decode_response(old_reply)
|
|
assert isinstance(decoded, HandshakeResponse)
|
|
assert decoded.ok is False
|
|
assert decoded.pid is None
|
|
assert decoded.daemon_version == "0.2.37"
|
|
|
|
|
|
def test_encode_decode_search_request_with_defaults() -> None:
|
|
req = SearchRequest(project_root="/tmp", query="test")
|
|
data = encode_request(req)
|
|
decoded = decode_request(data)
|
|
assert isinstance(decoded, SearchRequest)
|
|
assert decoded.languages is None
|
|
assert decoded.limit == 5
|
|
assert decoded.offset == 0
|
|
|
|
|
|
def test_encode_decode_search_request_with_all_fields() -> None:
|
|
req = SearchRequest(
|
|
project_root="/tmp/proj",
|
|
query="hello world",
|
|
languages=["python", "rust"],
|
|
paths=["src/*"],
|
|
limit=20,
|
|
offset=5,
|
|
)
|
|
data = encode_request(req)
|
|
decoded = decode_request(data)
|
|
assert isinstance(decoded, SearchRequest)
|
|
assert decoded.project_root == "/tmp/proj"
|
|
assert decoded.query == "hello world"
|
|
assert decoded.languages == ["python", "rust"]
|
|
assert decoded.paths == ["src/*"]
|
|
assert decoded.limit == 20
|
|
assert decoded.offset == 5
|
|
|
|
|
|
def test_encode_decode_search_response_with_results() -> None:
|
|
resp = SearchResponse(
|
|
success=True,
|
|
results=[
|
|
SearchResult(
|
|
file_path="main.py",
|
|
language="python",
|
|
content="def foo(): pass",
|
|
start_line=1,
|
|
end_line=1,
|
|
score=0.95,
|
|
),
|
|
],
|
|
total_returned=1,
|
|
offset=0,
|
|
)
|
|
data = encode_response(resp)
|
|
decoded = decode_response(data)
|
|
assert isinstance(decoded, SearchResponse)
|
|
assert decoded.success is True
|
|
assert len(decoded.results) == 1
|
|
assert decoded.results[0].file_path == "main.py"
|
|
assert decoded.results[0].score == 0.95
|
|
|
|
|
|
def test_encode_decode_error_response() -> None:
|
|
resp = ErrorResponse(message="something failed")
|
|
data = encode_response(resp)
|
|
decoded = decode_response(data)
|
|
assert isinstance(decoded, ErrorResponse)
|
|
assert decoded.message == "something failed"
|
|
|
|
|
|
def test_encode_decode_daemon_status_response() -> None:
|
|
resp = DaemonStatusResponse(
|
|
version="1.0.0",
|
|
uptime_seconds=42.5,
|
|
projects=[
|
|
DaemonProjectInfo(project_root="/tmp/proj", indexing=False),
|
|
],
|
|
idle_seconds=12.5,
|
|
idle_timeout_minutes=180,
|
|
)
|
|
data = encode_response(resp)
|
|
decoded = decode_response(data)
|
|
assert isinstance(decoded, DaemonStatusResponse)
|
|
assert decoded.version == "1.0.0"
|
|
assert decoded.uptime_seconds == 42.5
|
|
assert len(decoded.projects) == 1
|
|
assert decoded.projects[0].project_root == "/tmp/proj"
|
|
assert decoded.projects[0].indexing is False
|
|
assert decoded.idle_seconds == 12.5
|
|
assert decoded.idle_timeout_minutes == 180
|
|
|
|
|
|
def test_encode_decode_heartbeat_round_trip() -> None:
|
|
req = HeartbeatRequest()
|
|
decoded_req = decode_request(encode_request(req))
|
|
assert isinstance(decoded_req, HeartbeatRequest)
|
|
|
|
resp = HeartbeatResponse(ok=True)
|
|
decoded_resp = decode_response(encode_response(resp))
|
|
assert isinstance(decoded_resp, HeartbeatResponse)
|
|
assert decoded_resp.ok is True
|
|
|
|
|
|
def test_tagged_union_dispatch() -> None:
|
|
req = IndexRequest(project_root="/tmp")
|
|
data = encode_request(req)
|
|
decoded = decode_request(data)
|
|
assert isinstance(decoded, IndexRequest)
|
|
assert not isinstance(decoded, HandshakeRequest)
|
|
|
|
|
|
def test_encode_decode_doctor_request() -> None:
|
|
req = DoctorRequest(project_root="/tmp/proj")
|
|
data = encode_request(req)
|
|
decoded = decode_request(data)
|
|
assert isinstance(decoded, DoctorRequest)
|
|
assert decoded.project_root == "/tmp/proj"
|
|
|
|
|
|
def test_encode_decode_doctor_request_no_project() -> None:
|
|
req = DoctorRequest()
|
|
data = encode_request(req)
|
|
decoded = decode_request(data)
|
|
assert isinstance(decoded, DoctorRequest)
|
|
assert decoded.project_root is None
|
|
|
|
|
|
def test_encode_decode_doctor_response() -> None:
|
|
result = DoctorCheckResult(
|
|
name="Model Check", ok=True, details=["Embedding dimension: 384"], errors=[]
|
|
)
|
|
resp = DoctorResponse(result=result, final=False)
|
|
data = encode_response(resp)
|
|
decoded = decode_response(data)
|
|
assert isinstance(decoded, DoctorResponse)
|
|
assert decoded.result.name == "Model Check"
|
|
assert decoded.result.ok is True
|
|
assert decoded.result.details == ["Embedding dimension: 384"]
|
|
assert decoded.final is False
|
|
|
|
|
|
def test_encode_decode_doctor_response_final() -> None:
|
|
result = DoctorCheckResult(name="done", ok=True, details=[], errors=[])
|
|
resp = DoctorResponse(result=result, final=True)
|
|
data = encode_response(resp)
|
|
decoded = decode_response(data)
|
|
assert isinstance(decoded, DoctorResponse)
|
|
assert decoded.final is True
|
|
|
|
|
|
def test_encode_decode_daemon_env_request() -> None:
|
|
req = DaemonEnvRequest()
|
|
data = encode_request(req)
|
|
decoded = decode_request(data)
|
|
assert isinstance(decoded, DaemonEnvRequest)
|
|
|
|
|
|
def test_encode_decode_daemon_env_response() -> None:
|
|
resp = DaemonEnvResponse(
|
|
env_names=["HOME", "PATH", "GEMINI_API_KEY"],
|
|
settings_env_names=["GEMINI_API_KEY"],
|
|
)
|
|
data = encode_response(resp)
|
|
decoded = decode_response(data)
|
|
assert isinstance(decoded, DaemonEnvResponse)
|
|
assert decoded.env_names == ["HOME", "PATH", "GEMINI_API_KEY"]
|
|
assert decoded.settings_env_names == ["GEMINI_API_KEY"]
|
|
|
|
|
|
def test_all_request_types_round_trip() -> None:
|
|
requests: list[Request] = [
|
|
HandshakeRequest(version="1.0.0"),
|
|
IndexRequest(project_root="/tmp"),
|
|
SearchRequest(project_root="/tmp", query="test"),
|
|
ProjectStatusRequest(project_root="/tmp"),
|
|
DaemonStatusRequest(),
|
|
RemoveProjectRequest(project_root="/tmp"),
|
|
StopRequest(),
|
|
DoctorRequest(project_root="/tmp"),
|
|
DaemonEnvRequest(),
|
|
HeartbeatRequest(),
|
|
]
|
|
for req in requests:
|
|
data = encode_request(req)
|
|
decoded = decode_request(data)
|
|
assert type(decoded) is type(req)
|
|
|
|
|
|
def test_encode_decode_index_waiting_notice() -> None:
|
|
resp = IndexWaitingNotice()
|
|
data = encode_response(resp)
|
|
decoded = decode_response(data)
|
|
assert isinstance(decoded, IndexWaitingNotice)
|
|
|
|
|
|
def test_encode_decode_index_progress_update() -> None:
|
|
progress = IndexingProgress(
|
|
num_execution_starts=10,
|
|
num_unchanged=3,
|
|
num_adds=5,
|
|
num_deletes=1,
|
|
num_reprocesses=0,
|
|
num_errors=1,
|
|
)
|
|
resp = IndexProgressUpdate(progress=progress)
|
|
data = encode_response(resp)
|
|
decoded = decode_response(data)
|
|
assert isinstance(decoded, IndexProgressUpdate)
|
|
assert decoded.progress.num_execution_starts == 10
|
|
assert decoded.progress.num_unchanged == 3
|
|
assert decoded.progress.num_adds == 5
|
|
assert decoded.progress.num_deletes == 1
|
|
assert decoded.progress.num_reprocesses == 0
|
|
assert decoded.progress.num_errors == 1
|
|
|
|
|
|
def test_encode_decode_project_status_with_progress() -> None:
|
|
progress = IndexingProgress(
|
|
num_execution_starts=7,
|
|
num_unchanged=2,
|
|
num_adds=4,
|
|
num_deletes=0,
|
|
num_reprocesses=1,
|
|
num_errors=0,
|
|
)
|
|
resp = ProjectStatusResponse(
|
|
indexing=True,
|
|
total_chunks=50,
|
|
total_files=10,
|
|
languages={"python": 50},
|
|
progress=progress,
|
|
)
|
|
data = encode_response(resp)
|
|
decoded = decode_response(data)
|
|
assert isinstance(decoded, ProjectStatusResponse)
|
|
assert decoded.progress is not None
|
|
assert decoded.progress.num_execution_starts == 7
|
|
assert decoded.progress.num_adds == 4
|
|
|
|
|
|
def test_encode_decode_project_status_without_progress() -> None:
|
|
resp = ProjectStatusResponse(
|
|
indexing=False,
|
|
total_chunks=50,
|
|
total_files=10,
|
|
languages={"python": 50},
|
|
)
|
|
data = encode_response(resp)
|
|
decoded = decode_response(data)
|
|
assert isinstance(decoded, ProjectStatusResponse)
|
|
assert decoded.progress is None
|
|
|
|
|
|
def test_all_response_types_round_trip() -> None:
|
|
responses: list[Response] = [
|
|
IndexResponse(success=True),
|
|
IndexProgressUpdate(
|
|
progress=IndexingProgress(
|
|
num_execution_starts=0,
|
|
num_unchanged=0,
|
|
num_adds=0,
|
|
num_deletes=0,
|
|
num_reprocesses=0,
|
|
num_errors=0,
|
|
)
|
|
),
|
|
IndexWaitingNotice(),
|
|
SearchResponse(success=True),
|
|
ProjectStatusResponse(indexing=False, total_chunks=0, total_files=0, languages={}),
|
|
DaemonStatusResponse(
|
|
version="1.0.0",
|
|
uptime_seconds=0.0,
|
|
projects=[],
|
|
idle_seconds=0.0,
|
|
idle_timeout_minutes=180,
|
|
),
|
|
RemoveProjectResponse(ok=True),
|
|
StopResponse(ok=True),
|
|
HeartbeatResponse(ok=True),
|
|
DoctorResponse(
|
|
result=DoctorCheckResult(name="test", ok=True, details=[], errors=[]),
|
|
),
|
|
DaemonEnvResponse(env_names=["HOME"], settings_env_names=[]),
|
|
ErrorResponse(message="err"),
|
|
]
|
|
for resp in responses:
|
|
data = encode_response(resp)
|
|
decoded = decode_response(data)
|
|
assert type(decoded) is type(resp)
|