Files
JunZ 3ce9fb5dc9 fix(client): crash-aware restart of a daemon that vanished mid-session (#214)
* fix(client): crash-aware restart of a daemon that vanished mid-session

A ConnectionRefusedError after the daemon was already ensured used to be
surfaced as an error, even though a vanished daemon is usually restartable
(manual ccc daemon stop, and soon idle-exit). But blindly restarting would
silently mask daemon crashes and could relaunch a crash-looping daemon
forever. Distinguish the two:

- The daemon's graceful shutdown path (StopRequest, SIGTERM/SIGINT) now
  writes a last_exit marker file {pid, reason, timestamp} in the runtime
  dir, removed again at the next daemon startup. A crashed daemon (SIGKILL,
  OOM, segfault) never writes it.
- HandshakeResponse carries the daemon's pid, so the client can match the
  marker against the exact process it was talking to — race-free, no
  wall-clock comparisons.
- On a vanished ensured daemon: marker with matching pid → graceful exit →
  silent transparent restart. No marker / pid mismatch → crash → restart
  once but warn on stderr pointing at the daemon log; after 3 consecutive
  crashes raise instead of relaunching. The streak resets whenever the
  daemon answers without needing a restart.

The fail-fast for a genuine version mismatch (binary swapped under us
mid-session) is kept unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaFKwEvJXo7WQhZvrW5Qoe

* fix(tests): avoid signal.SIGKILL on Windows to satisfy mypy

signal.SIGKILL does not exist on Windows, failing the pre-commit mypy
check there. Guard on sys.platform and fall back to SIGTERM, which on
Windows means TerminateProcess — equally abrupt, so the crash-marker
assertion still holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeERy25Ct8dMFMVxYSk51c

* refactor(client): use _HandshakeResult field accessors at call sites

Per review feedback on #214: carry the handshake result as one NamedTuple
and access .conn / .resp by name instead of tuple-unpacking, so field
reordering can't silently misalign call sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:09:01 -07:00

294 lines
11 KiB
Python

"""End-to-end tests for the CLI → daemon subprocess flow.
These tests start a real daemon subprocess via ``start_daemon()`` and interact
with it through the per-request client functions, mirroring how ``ccc index`` /
``ccc search`` actually work.
"""
from __future__ import annotations
import os
import signal
import sys
import tempfile
import time
from collections.abc import Iterator
from pathlib import Path
import pytest
from conftest import make_test_user_settings
from cocoindex_code import client
from cocoindex_code._version import __version__
from cocoindex_code.client import start_daemon, stop_daemon
from cocoindex_code.daemon import daemon_socket_path
from cocoindex_code.settings import (
default_project_settings,
save_project_settings,
save_user_settings,
)
SAMPLE_PY = '''\
"""Sample module."""
def calculate_fibonacci(n: int) -> int:
"""Calculate the nth Fibonacci number."""
if n <= 1:
return n
return calculate_fibonacci(n - 1) + calculate_fibonacci(n - 2)
'''
@pytest.fixture(scope="module")
def e2e_daemon() -> Iterator[tuple[str, Path]]:
"""Start a real daemon subprocess and return (sock_path, project_dir).
Uses COCOINDEX_CODE_DIR env var so the subprocess uses the temp directory.
"""
# Use a short temp dir to stay within AF_UNIX path limit
base_dir = Path(tempfile.mkdtemp(prefix="ccc_e2e_"))
project_dir = base_dir / "proj"
project_dir.mkdir()
(project_dir / "main.py").write_text(SAMPLE_PY)
# Set env var BEFORE calling any daemon/settings functions
old_env = os.environ.get("COCOINDEX_CODE_DIR")
os.environ["COCOINDEX_CODE_DIR"] = str(base_dir)
try:
save_user_settings(make_test_user_settings())
save_project_settings(project_dir, default_project_settings())
proc = start_daemon()
sock_path = daemon_socket_path()
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
if proc.poll() is not None:
log = base_dir / "daemon.log"
log_content = log.read_text() if log.exists() else "(no log)"
raise RuntimeError(f"Daemon process exited early.\nLog:\n{log_content}")
if os.path.exists(sock_path):
break
time.sleep(0.2)
else:
log = base_dir / "daemon.log"
log_content = log.read_text() if log.exists() else "(no log)"
raise TimeoutError(f"Daemon did not start.\nLog:\n{log_content}")
yield sock_path, project_dir
finally:
stop_daemon()
if old_env is None:
os.environ.pop("COCOINDEX_CODE_DIR", None)
else:
os.environ["COCOINDEX_CODE_DIR"] = old_env
def test_daemon_subprocess_starts(e2e_daemon: tuple[str, Path]) -> None:
"""The daemon should be reachable via a fresh connection after start_daemon()."""
resp = client.daemon_status()
assert resp.version == __version__
def test_index_and_search_via_client(e2e_daemon: tuple[str, Path]) -> None:
"""Index a project and search via the client, same as ccc index / ccc search."""
_, project_dir = e2e_daemon
resp = client.index(str(project_dir))
assert resp.success
status = client.project_status(str(project_dir))
assert status.total_chunks > 0
assert status.total_files > 0
search_resp = client.search(str(project_dir), query="fibonacci")
assert search_resp.success
assert len(search_resp.results) > 0
assert "main.py" in search_resp.results[0].file_path
# ---------------------------------------------------------------------------
# No-settings mode + host_path_mappings wiring
# ---------------------------------------------------------------------------
def test_daemon_starts_in_no_settings_mode_without_global_settings() -> None:
"""Daemon started against an empty COCOINDEX_CODE_DIR should come up without
creating ``global_settings.yml``. The file stays absent; the handshake reports
``mtime=None``. Project requests are rejected with a clear "run `ccc init`" error.
"""
from cocoindex_code.client import stop_daemon as _stop
from cocoindex_code.protocol import ProjectStatusRequest, encode_request
from cocoindex_code.settings import user_settings_path
base_dir = Path(tempfile.mkdtemp(prefix="ccc_nosettings_"))
old_env = os.environ.get("COCOINDEX_CODE_DIR")
os.environ["COCOINDEX_CODE_DIR"] = str(base_dir)
try:
assert not user_settings_path().is_file()
proc = start_daemon()
sock = daemon_socket_path()
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
if proc.poll() is not None:
log = base_dir / "daemon.log"
raise RuntimeError(
f"Daemon exited early. Log:\n{log.read_text() if log.exists() else '(none)'}"
)
if os.path.exists(sock):
break
time.sleep(0.2)
else:
raise TimeoutError("Daemon did not start in time")
# Daemon is up, but global_settings.yml stays absent — no auto-create.
assert not user_settings_path().is_file()
# Handshake works and reports mtime=None (no settings yet).
# Use the lower-level raw handshake so we can inspect the response
# directly; the high-level client would loop on mtime mismatch.
from cocoindex_code.client import _raw_connect_and_handshake
from cocoindex_code.protocol import decode_response
# _raw_connect_and_handshake does its own handshake read — but it also
# raises DaemonVersionError when the client-side mtime disagrees. With
# the file absent on both sides, mtime=None matches, so handshake OK.
conn = _raw_connect_and_handshake().conn
try:
# Send a project request — should get an ErrorResponse pointing at
# `ccc init`, not a crash.
conn.send_bytes(encode_request(ProjectStatusRequest(project_root=str(base_dir))))
resp = decode_response(conn.recv_bytes())
finally:
conn.close()
from cocoindex_code.protocol import ErrorResponse
assert isinstance(resp, ErrorResponse)
assert "ccc init" in resp.message
finally:
_stop()
if old_env is None:
os.environ.pop("COCOINDEX_CODE_DIR", None)
else:
os.environ["COCOINDEX_CODE_DIR"] = old_env
def test_daemon_env_response_includes_host_path_mappings(
e2e_daemon: tuple[str, Path],
) -> None:
"""``client.daemon_env`` surfaces the parsed COCOINDEX_CODE_HOST_PATH_MAPPING."""
_, _project_dir = e2e_daemon
# The session daemon was started without COCOINDEX_CODE_HOST_PATH_MAPPING,
# so this just verifies the field is exposed on the wire and defaults to empty.
resp = client.daemon_env()
assert hasattr(resp, "host_path_mappings")
assert resp.host_path_mappings == []
def test_client_restarts_daemon_silently_after_graceful_exit(
e2e_daemon: tuple[str, Path],
capsys: pytest.CaptureFixture[str],
) -> None:
"""After a successful request (``_daemon_ensured`` True), a daemon that
exits gracefully (StopRequest) leaves a graceful-exit marker and is
transparently restarted by the next request, with no crash warning.
The fresh daemon removes the marker at startup.
NOTE: keep this test near the end of the module — it stops the shared
daemon and relies on the client to bring a fresh one back up.
"""
from cocoindex_code._daemon_paths import daemon_last_exit_path, read_last_exit_marker
from cocoindex_code.protocol import StopRequest, encode_request
# Establish a session: this sets client._daemon_ensured = True.
resp = client.daemon_status()
assert resp.version == __version__
assert client._daemon_ensured is True
daemon_pid = client._ensured_daemon_pid
assert daemon_pid is not None
# Stop the daemon *without* client.stop_daemon(), which would reset
# _daemon_ensured and hide the race under test.
conn = client._raw_connect_and_handshake().conn
try:
conn.send_bytes(encode_request(StopRequest()))
conn.recv_bytes()
finally:
conn.close()
assert client._wait_for_daemon_exit(timeout=10.0), "daemon did not exit"
# The graceful exit left a marker with the stopped daemon's pid and reason.
marker = read_last_exit_marker()
assert marker is not None
assert marker.pid == daemon_pid
assert marker.reason == "stop_request"
# The next request must transparently restart the daemon and succeed —
# silently, since the exit was graceful.
capsys.readouterr() # drain anything printed so far
resp2 = client.daemon_status()
assert resp2.version == __version__
assert "exited unexpectedly" not in capsys.readouterr().err
# The fresh daemon removed the marker at startup.
assert not daemon_last_exit_path().exists()
def test_client_warns_and_restarts_after_daemon_crash(
e2e_daemon: tuple[str, Path],
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A SIGKILLed daemon leaves no graceful-exit marker; the next request
warns on stderr but still succeeds via a restart.
NOTE: keep this test last in the module — it kills the shared daemon and
relies on the client to bring a fresh one back up.
"""
from multiprocessing.connection import Client as _RawClient
from cocoindex_code._daemon_paths import connection_family, daemon_last_exit_path
monkeypatch.setattr(client, "_consecutive_crash_restarts", 0)
# Establish a session and remember the daemon's pid from the handshake.
resp = client.daemon_status()
assert resp.version == __version__
daemon_pid = client._ensured_daemon_pid
assert daemon_pid is not None
if sys.platform == "win32":
# No SIGKILL on Windows; os.kill with any non-CTRL signal calls
# TerminateProcess, which is equally abrupt (no handlers run).
os.kill(daemon_pid, signal.SIGTERM)
else:
os.kill(daemon_pid, signal.SIGKILL)
# Wait until the listener is actually gone (the socket file lingers after
# SIGKILL, but connecting to it starts failing once the process died).
sock_path, _ = e2e_daemon
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
try:
probe = _RawClient(sock_path, family=connection_family())
probe.close()
time.sleep(0.1)
except OSError:
break
else:
raise TimeoutError("daemon listener still up after SIGKILL")
assert not daemon_last_exit_path().exists(), "a crash must not leave a marker"
# The next request warns but still succeeds via restart.
capsys.readouterr() # drain
resp2 = client.daemon_status()
assert resp2.version == __version__
assert "exited unexpectedly" in capsys.readouterr().err
assert client._consecutive_crash_restarts == 1