* fix(embed): clear stale daemon on port before starting new one (#843) When `uvx hindsight-embed@latest` resolves to a new version, the old daemon may still be bound to the port, causing EADDRINUSE. Before starting a daemon, check if the port is occupied, verify it's a hindsight process via /health, and SIGTERM it if so. * chore: remove unused signal import from test * refactor: use cross-platform port check instead of lsof-only Use socket for port check (works on all platforms), extract PID lookup into a helper with Windows (netstat) and Unix (lsof) paths, and extract kill logic into a testable static method. * refactor: reuse cross-platform helpers in stop() and stop_ui()
272 lines
11 KiB
Python
272 lines
11 KiB
Python
"""Tests for daemon_client module."""
|
|
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from hindsight_embed import daemon_client
|
|
from hindsight_embed.daemon_embed_manager import DaemonEmbedManager
|
|
|
|
@pytest.fixture
|
|
def config():
|
|
"""Default config for tests."""
|
|
return {
|
|
"llm_api_key": "test-key",
|
|
"llm_provider": "openai",
|
|
"llm_model": "gpt-4o-mini",
|
|
"bank_id": "test-bank",
|
|
}
|
|
|
|
@pytest.fixture
|
|
def mock_cli_binary(tmp_path):
|
|
"""Create a mock CLI binary."""
|
|
cli_path = tmp_path / "hindsight"
|
|
cli_path.write_text("#!/bin/bash\nexit 0")
|
|
cli_path.chmod(0o755)
|
|
return cli_path
|
|
|
|
class TestRunCli:
|
|
"""Tests for run_cli function."""
|
|
|
|
def test_run_cli_with_external_api_url(self, config, mock_cli_binary, monkeypatch):
|
|
"""Test that external HINDSIGHT_EMBED_API_URL skips daemon startup."""
|
|
# Set up environment with external API URL
|
|
external_api_url = "http://external-api:8000"
|
|
monkeypatch.setenv("HINDSIGHT_EMBED_API_URL", external_api_url)
|
|
|
|
# Mock functions
|
|
mock_ensure_cli = Mock(return_value=True)
|
|
mock_find_cli = Mock(return_value=mock_cli_binary)
|
|
mock_ensure_daemon = Mock(return_value=True)
|
|
mock_subprocess_run = Mock(return_value=Mock(returncode=0))
|
|
|
|
with (
|
|
patch.object(daemon_client, "ensure_cli_installed", mock_ensure_cli),
|
|
patch.object(daemon_client, "find_cli_binary", mock_find_cli),
|
|
patch.object(daemon_client, "ensure_daemon_running", mock_ensure_daemon),
|
|
patch("subprocess.run", mock_subprocess_run),
|
|
):
|
|
# Run CLI
|
|
exit_code = daemon_client.run_cli(["memory", "recall", "test", "query"], config)
|
|
|
|
# Verify daemon was NOT started (since external API URL is set)
|
|
assert mock_ensure_daemon.call_count == 0
|
|
|
|
# Verify CLI was called
|
|
assert mock_subprocess_run.call_count == 1
|
|
call_args = mock_subprocess_run.call_args
|
|
|
|
# Verify environment contains the external API URL
|
|
assert call_args.kwargs["env"]["HINDSIGHT_API_URL"] == external_api_url
|
|
|
|
# Verify exit code
|
|
assert exit_code == 0
|
|
|
|
def test_run_cli_without_external_api_url(self, config, mock_cli_binary, monkeypatch):
|
|
"""Test that without external API URL, daemon is started."""
|
|
# Ensure HINDSIGHT_EMBED_API_URL is not set
|
|
monkeypatch.delenv("HINDSIGHT_EMBED_API_URL", raising=False)
|
|
|
|
# Mock functions
|
|
mock_ensure_cli = Mock(return_value=True)
|
|
mock_find_cli = Mock(return_value=mock_cli_binary)
|
|
mock_ensure_daemon = Mock(return_value=True)
|
|
mock_subprocess_run = Mock(return_value=Mock(returncode=0))
|
|
|
|
with (
|
|
patch.object(daemon_client, "ensure_cli_installed", mock_ensure_cli),
|
|
patch.object(daemon_client, "find_cli_binary", mock_find_cli),
|
|
patch.object(daemon_client, "ensure_daemon_running", mock_ensure_daemon),
|
|
patch("subprocess.run", mock_subprocess_run),
|
|
):
|
|
# Run CLI
|
|
exit_code = daemon_client.run_cli(["memory", "recall", "test", "query"], config)
|
|
|
|
# Verify daemon WAS started (since no external API URL)
|
|
assert mock_ensure_daemon.call_count == 1
|
|
assert mock_ensure_daemon.call_args[0][0] == config
|
|
|
|
# Verify CLI was called
|
|
assert mock_subprocess_run.call_count == 1
|
|
call_args = mock_subprocess_run.call_args
|
|
|
|
# Verify environment contains the local daemon URL
|
|
assert call_args.kwargs["env"]["HINDSIGHT_API_URL"] == daemon_client.get_daemon_url()
|
|
|
|
# Verify exit code
|
|
assert exit_code == 0
|
|
|
|
def test_run_cli_daemon_startup_failure(self, config, mock_cli_binary, monkeypatch):
|
|
"""Test that daemon startup failure is handled properly."""
|
|
# Ensure HINDSIGHT_EMBED_API_URL is not set
|
|
monkeypatch.delenv("HINDSIGHT_EMBED_API_URL", raising=False)
|
|
|
|
# Mock functions - daemon startup fails
|
|
mock_ensure_cli = Mock(return_value=True)
|
|
mock_find_cli = Mock(return_value=mock_cli_binary)
|
|
mock_ensure_daemon = Mock(return_value=False) # Daemon fails to start
|
|
|
|
with (
|
|
patch.object(daemon_client, "ensure_cli_installed", mock_ensure_cli),
|
|
patch.object(daemon_client, "find_cli_binary", mock_find_cli),
|
|
patch.object(daemon_client, "ensure_daemon_running", mock_ensure_daemon),
|
|
):
|
|
# Run CLI
|
|
exit_code = daemon_client.run_cli(["memory", "recall", "test", "query"], config)
|
|
|
|
# Verify daemon startup was attempted
|
|
assert mock_ensure_daemon.call_count == 1
|
|
|
|
# Verify exit code indicates failure
|
|
assert exit_code == 1
|
|
|
|
def test_run_cli_without_cli_binary(self, config, monkeypatch):
|
|
"""Test that missing CLI binary is handled properly."""
|
|
# Ensure HINDSIGHT_EMBED_API_URL is not set
|
|
monkeypatch.delenv("HINDSIGHT_EMBED_API_URL", raising=False)
|
|
|
|
# Mock functions - CLI not installed
|
|
mock_ensure_cli = Mock(return_value=True)
|
|
mock_find_cli = Mock(return_value=None) # CLI not found
|
|
|
|
with (
|
|
patch.object(daemon_client, "ensure_cli_installed", mock_ensure_cli),
|
|
patch.object(daemon_client, "find_cli_binary", mock_find_cli),
|
|
):
|
|
# Run CLI
|
|
exit_code = daemon_client.run_cli(["memory", "recall", "test", "query"], config)
|
|
|
|
# Verify exit code indicates failure
|
|
assert exit_code == 1
|
|
|
|
def test_run_cli_with_api_token(self, config, mock_cli_binary, monkeypatch):
|
|
"""Test that HINDSIGHT_EMBED_API_TOKEN is passed through to the CLI."""
|
|
# Set up environment with external API URL and token
|
|
external_api_url = "http://external-api:8000"
|
|
api_token = "test-bearer-token-12345"
|
|
monkeypatch.setenv("HINDSIGHT_EMBED_API_URL", external_api_url)
|
|
monkeypatch.setenv("HINDSIGHT_EMBED_API_TOKEN", api_token)
|
|
|
|
# Mock functions
|
|
mock_ensure_cli = Mock(return_value=True)
|
|
mock_find_cli = Mock(return_value=mock_cli_binary)
|
|
mock_ensure_daemon = Mock(return_value=True)
|
|
mock_subprocess_run = Mock(return_value=Mock(returncode=0))
|
|
|
|
with (
|
|
patch.object(daemon_client, "ensure_cli_installed", mock_ensure_cli),
|
|
patch.object(daemon_client, "find_cli_binary", mock_find_cli),
|
|
patch.object(daemon_client, "ensure_daemon_running", mock_ensure_daemon),
|
|
patch("subprocess.run", mock_subprocess_run),
|
|
):
|
|
# Run CLI
|
|
exit_code = daemon_client.run_cli(["memory", "recall", "test", "query"], config)
|
|
|
|
# Verify daemon was NOT started (since external API URL is set)
|
|
assert mock_ensure_daemon.call_count == 0
|
|
|
|
# Verify CLI was called
|
|
assert mock_subprocess_run.call_count == 1
|
|
call_args = mock_subprocess_run.call_args
|
|
|
|
# Verify environment contains both the API URL and the API key
|
|
assert call_args.kwargs["env"]["HINDSIGHT_API_URL"] == external_api_url
|
|
assert call_args.kwargs["env"]["HINDSIGHT_API_KEY"] == api_token
|
|
|
|
# Verify exit code
|
|
assert exit_code == 0
|
|
|
|
|
|
class TestClearPort:
|
|
"""Tests for DaemonEmbedManager._clear_port."""
|
|
|
|
def test_port_free(self):
|
|
"""Port not in use — returns True immediately."""
|
|
manager = DaemonEmbedManager()
|
|
with patch.object(DaemonEmbedManager, "_is_port_in_use", return_value=False):
|
|
assert manager._clear_port(9555) is True
|
|
|
|
def test_port_occupied_by_hindsight_stops_it(self):
|
|
"""Port occupied by a hindsight daemon — kills it and returns True."""
|
|
manager = DaemonEmbedManager()
|
|
with (
|
|
patch.object(DaemonEmbedManager, "_is_port_in_use", return_value=True),
|
|
patch("httpx.Client") as mock_httpx_cls,
|
|
patch.object(DaemonEmbedManager, "_find_pid_on_port", return_value=12345),
|
|
patch.object(DaemonEmbedManager, "_kill_process", return_value=True),
|
|
):
|
|
mock_client = MagicMock()
|
|
mock_client.__enter__ = Mock(return_value=mock_client)
|
|
mock_client.__exit__ = Mock(return_value=False)
|
|
mock_client.get.return_value = Mock(status_code=200)
|
|
mock_httpx_cls.return_value = mock_client
|
|
|
|
assert manager._clear_port(9555) is True
|
|
|
|
def test_port_occupied_by_non_hindsight_returns_false(self):
|
|
"""Port occupied by non-hindsight process — returns False."""
|
|
manager = DaemonEmbedManager()
|
|
with (
|
|
patch.object(DaemonEmbedManager, "_is_port_in_use", return_value=True),
|
|
patch("httpx.Client") as mock_httpx_cls,
|
|
):
|
|
mock_client = MagicMock()
|
|
mock_client.__enter__ = Mock(return_value=mock_client)
|
|
mock_client.__exit__ = Mock(return_value=False)
|
|
mock_client.get.side_effect = httpx.ConnectError("Connection refused")
|
|
mock_httpx_cls.return_value = mock_client
|
|
|
|
assert manager._clear_port(9555) is False
|
|
|
|
def test_port_occupied_health_non_200_returns_false(self):
|
|
"""Port responds but not with 200 — treated as non-hindsight."""
|
|
manager = DaemonEmbedManager()
|
|
with (
|
|
patch.object(DaemonEmbedManager, "_is_port_in_use", return_value=True),
|
|
patch("httpx.Client") as mock_httpx_cls,
|
|
):
|
|
mock_client = MagicMock()
|
|
mock_client.__enter__ = Mock(return_value=mock_client)
|
|
mock_client.__exit__ = Mock(return_value=False)
|
|
mock_client.get.return_value = Mock(status_code=404)
|
|
mock_httpx_cls.return_value = mock_client
|
|
|
|
assert manager._clear_port(9555) is False
|
|
|
|
def test_pid_not_found_returns_false(self):
|
|
"""Hindsight daemon on port but can't find PID — returns False."""
|
|
manager = DaemonEmbedManager()
|
|
with (
|
|
patch.object(DaemonEmbedManager, "_is_port_in_use", return_value=True),
|
|
patch("httpx.Client") as mock_httpx_cls,
|
|
patch.object(DaemonEmbedManager, "_find_pid_on_port", return_value=None),
|
|
):
|
|
mock_client = MagicMock()
|
|
mock_client.__enter__ = Mock(return_value=mock_client)
|
|
mock_client.__exit__ = Mock(return_value=False)
|
|
mock_client.get.return_value = Mock(status_code=200)
|
|
mock_httpx_cls.return_value = mock_client
|
|
|
|
assert manager._clear_port(9555) is False
|
|
|
|
def test_kill_fails_returns_false(self):
|
|
"""Hindsight daemon found but won't die — returns False."""
|
|
manager = DaemonEmbedManager()
|
|
with (
|
|
patch.object(DaemonEmbedManager, "_is_port_in_use", return_value=True),
|
|
patch("httpx.Client") as mock_httpx_cls,
|
|
patch.object(DaemonEmbedManager, "_find_pid_on_port", return_value=12345),
|
|
patch.object(DaemonEmbedManager, "_kill_process", return_value=False),
|
|
):
|
|
mock_client = MagicMock()
|
|
mock_client.__enter__ = Mock(return_value=mock_client)
|
|
mock_client.__exit__ = Mock(return_value=False)
|
|
mock_client.get.return_value = Mock(status_code=200)
|
|
mock_httpx_cls.return_value = mock_client
|
|
|
|
assert manager._clear_port(9555) is False
|
|
|