mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-21 03:39:54 +00:00
82cb1752d9
fuser command does not exist on Windows, causing orphaned bridge processes to never be cleaned up. On crash recovery, the port stays occupied and the next connect() fails with address-already-in-use. Add _kill_port_process() helper that uses netstat+taskkill on Windows and fuser on Linux/macOS. Replace both call sites in connect() and disconnect().
362 lines
13 KiB
Python
362 lines
13 KiB
Python
"""Tests for WhatsApp connect() error handling.
|
|
|
|
Regression tests for two bugs in WhatsAppAdapter.connect():
|
|
|
|
1. Uninitialized ``data`` variable: when ``resp.json()`` raised after the
|
|
health endpoint returned HTTP 200, ``http_ready`` was set to True but
|
|
``data`` was never assigned. The subsequent ``data.get("status")``
|
|
check raised ``NameError``.
|
|
|
|
2. Bridge log file handle leaked on error paths: the file was opened before
|
|
the health-check loop but never closed when ``connect()`` returned False.
|
|
Repeated connection failures accumulated open file descriptors.
|
|
"""
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _AsyncCM:
|
|
"""Minimal async context manager returning a fixed value."""
|
|
|
|
def __init__(self, value):
|
|
self.value = value
|
|
|
|
async def __aenter__(self):
|
|
return self.value
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
|
|
def _make_adapter():
|
|
"""Create a WhatsAppAdapter with test attributes (bypass __init__)."""
|
|
from gateway.platforms.whatsapp import WhatsAppAdapter
|
|
|
|
adapter = WhatsAppAdapter.__new__(WhatsAppAdapter)
|
|
adapter.platform = Platform.WHATSAPP
|
|
adapter.config = MagicMock()
|
|
adapter._bridge_port = 19876
|
|
adapter._bridge_script = "/tmp/test-bridge.js"
|
|
adapter._session_path = Path("/tmp/test-wa-session")
|
|
adapter._bridge_log_fh = None
|
|
adapter._bridge_log = None
|
|
adapter._bridge_process = None
|
|
adapter._running = False
|
|
adapter._message_queue = asyncio.Queue()
|
|
return adapter
|
|
|
|
|
|
def _mock_aiohttp(status=200, json_data=None, json_side_effect=None):
|
|
"""Build a mock ``aiohttp.ClientSession`` returning a fixed response."""
|
|
mock_resp = MagicMock()
|
|
mock_resp.status = status
|
|
if json_side_effect:
|
|
mock_resp.json = AsyncMock(side_effect=json_side_effect)
|
|
else:
|
|
mock_resp.json = AsyncMock(return_value=json_data or {})
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.get = MagicMock(return_value=_AsyncCM(mock_resp))
|
|
|
|
return MagicMock(return_value=_AsyncCM(mock_session))
|
|
|
|
|
|
def _connect_patches(mock_proc, mock_fh, mock_client_cls=None):
|
|
"""Return a dict of common patches needed to reach the health-check loop."""
|
|
patches = {
|
|
"gateway.platforms.whatsapp.check_whatsapp_requirements": True,
|
|
"gateway.platforms.whatsapp.asyncio.create_task": MagicMock(),
|
|
}
|
|
base = [
|
|
patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True),
|
|
patch.object(Path, "exists", return_value=True),
|
|
patch.object(Path, "mkdir", return_value=None),
|
|
patch("subprocess.run", return_value=MagicMock(returncode=0)),
|
|
patch("subprocess.Popen", return_value=mock_proc),
|
|
patch("builtins.open", return_value=mock_fh),
|
|
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock),
|
|
patch("gateway.platforms.whatsapp.asyncio.create_task"),
|
|
]
|
|
if mock_client_cls is not None:
|
|
base.append(patch("aiohttp.ClientSession", mock_client_cls))
|
|
return base
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _close_bridge_log() unit tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCloseBridgeLog:
|
|
"""Direct tests for the _close_bridge_log() helper method."""
|
|
|
|
@staticmethod
|
|
def _bare_adapter():
|
|
from gateway.platforms.whatsapp import WhatsAppAdapter
|
|
a = WhatsAppAdapter.__new__(WhatsAppAdapter)
|
|
a._bridge_log_fh = None
|
|
return a
|
|
|
|
def test_closes_open_handle(self):
|
|
adapter = self._bare_adapter()
|
|
mock_fh = MagicMock()
|
|
adapter._bridge_log_fh = mock_fh
|
|
|
|
adapter._close_bridge_log()
|
|
|
|
mock_fh.close.assert_called_once()
|
|
assert adapter._bridge_log_fh is None
|
|
|
|
def test_noop_when_no_handle(self):
|
|
adapter = self._bare_adapter()
|
|
|
|
adapter._close_bridge_log() # must not raise
|
|
|
|
assert adapter._bridge_log_fh is None
|
|
|
|
def test_suppresses_close_exception(self):
|
|
adapter = self._bare_adapter()
|
|
mock_fh = MagicMock()
|
|
mock_fh.close.side_effect = OSError("already closed")
|
|
adapter._bridge_log_fh = mock_fh
|
|
|
|
adapter._close_bridge_log() # must not raise
|
|
|
|
assert adapter._bridge_log_fh is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# data variable initialization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDataInitialized:
|
|
"""Verify ``data = {}`` prevents NameError when resp.json() fails."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_name_error_when_json_always_fails(self):
|
|
"""HTTP 200 sets http_ready but json() always raises.
|
|
|
|
Without the fix, ``data`` was never assigned and the Phase 2 check
|
|
``data.get("status")`` raised NameError. With ``data = {}``, the
|
|
check evaluates to ``None != "connected"`` and Phase 2 runs normally.
|
|
"""
|
|
adapter = _make_adapter()
|
|
|
|
mock_proc = MagicMock()
|
|
mock_proc.poll.return_value = None # bridge stays alive
|
|
|
|
mock_client_cls = _mock_aiohttp(
|
|
status=200, json_side_effect=ValueError("bad json"),
|
|
)
|
|
mock_fh = MagicMock()
|
|
|
|
patches = _connect_patches(mock_proc, mock_fh, mock_client_cls)
|
|
|
|
with patches[0], patches[1], patches[2], patches[3], patches[4], \
|
|
patches[5], patches[6], patches[7], patches[8], \
|
|
patch.object(type(adapter), "_poll_messages", return_value=MagicMock()):
|
|
# Must NOT raise NameError
|
|
result = await adapter.connect()
|
|
|
|
# connect() returns True (warn-and-proceed path)
|
|
assert result is True
|
|
assert adapter._running is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# File handle cleanup on error paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestFileHandleClosedOnError:
|
|
"""Verify the bridge log file handle is closed on every failure path."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_closed_when_bridge_dies_phase1(self):
|
|
"""Bridge process exits during Phase 1 health-check loop."""
|
|
adapter = _make_adapter()
|
|
|
|
mock_proc = MagicMock()
|
|
mock_proc.poll.return_value = 1 # dead immediately
|
|
mock_proc.returncode = 1
|
|
|
|
mock_fh = MagicMock()
|
|
patches = _connect_patches(mock_proc, mock_fh)
|
|
|
|
with patches[0], patches[1], patches[2], patches[3], patches[4], \
|
|
patches[5], patches[6], patches[7]:
|
|
result = await adapter.connect()
|
|
|
|
assert result is False
|
|
mock_fh.close.assert_called_once()
|
|
assert adapter._bridge_log_fh is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_closed_when_http_not_ready(self):
|
|
"""Health endpoint never returns 200 within 15 attempts."""
|
|
adapter = _make_adapter()
|
|
|
|
mock_proc = MagicMock()
|
|
mock_proc.poll.return_value = None # bridge alive
|
|
|
|
mock_client_cls = _mock_aiohttp(status=503)
|
|
mock_fh = MagicMock()
|
|
patches = _connect_patches(mock_proc, mock_fh, mock_client_cls)
|
|
|
|
with patches[0], patches[1], patches[2], patches[3], patches[4], \
|
|
patches[5], patches[6], patches[7], patches[8]:
|
|
result = await adapter.connect()
|
|
|
|
assert result is False
|
|
mock_fh.close.assert_called_once()
|
|
assert adapter._bridge_log_fh is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_closed_when_bridge_dies_phase2(self):
|
|
"""Bridge alive during Phase 1 but dies during Phase 2."""
|
|
adapter = _make_adapter()
|
|
|
|
# Phase 1 (15 iterations): alive. Phase 2 (iteration 16): dead.
|
|
call_count = [0]
|
|
|
|
def poll_side_effect():
|
|
call_count[0] += 1
|
|
return None if call_count[0] <= 15 else 1
|
|
|
|
mock_proc = MagicMock()
|
|
mock_proc.poll.side_effect = poll_side_effect
|
|
mock_proc.returncode = 1
|
|
|
|
# Health returns 200 with status != "connected" -> triggers Phase 2
|
|
mock_client_cls = _mock_aiohttp(
|
|
status=200, json_data={"status": "disconnected"},
|
|
)
|
|
mock_fh = MagicMock()
|
|
patches = _connect_patches(mock_proc, mock_fh, mock_client_cls)
|
|
|
|
with patches[0], patches[1], patches[2], patches[3], patches[4], \
|
|
patches[5], patches[6], patches[7], patches[8]:
|
|
result = await adapter.connect()
|
|
|
|
assert result is False
|
|
mock_fh.close.assert_called_once()
|
|
assert adapter._bridge_log_fh is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_closed_on_unexpected_exception(self):
|
|
"""Popen raises, outer except block must still close the handle."""
|
|
adapter = _make_adapter()
|
|
|
|
mock_fh = MagicMock()
|
|
|
|
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
|
patch.object(Path, "exists", return_value=True), \
|
|
patch.object(Path, "mkdir", return_value=None), \
|
|
patch("subprocess.run", return_value=MagicMock(returncode=0)), \
|
|
patch("subprocess.Popen", side_effect=OSError("spawn failed")), \
|
|
patch("builtins.open", return_value=mock_fh):
|
|
result = await adapter.connect()
|
|
|
|
assert result is False
|
|
mock_fh.close.assert_called_once()
|
|
assert adapter._bridge_log_fh is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _kill_port_process() cross-platform tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestKillPortProcess:
|
|
"""Verify _kill_port_process uses platform-appropriate commands."""
|
|
|
|
def test_uses_netstat_and_taskkill_on_windows(self):
|
|
from gateway.platforms.whatsapp import _kill_port_process
|
|
|
|
netstat_output = (
|
|
" Proto Local Address Foreign Address State PID\n"
|
|
" TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 12345\n"
|
|
" TCP 0.0.0.0:3001 0.0.0.0:0 LISTENING 99999\n"
|
|
)
|
|
mock_netstat = MagicMock(stdout=netstat_output)
|
|
mock_taskkill = MagicMock()
|
|
|
|
def run_side_effect(cmd, **kwargs):
|
|
if cmd[0] == "netstat":
|
|
return mock_netstat
|
|
if cmd[0] == "taskkill":
|
|
return mock_taskkill
|
|
return MagicMock()
|
|
|
|
with patch("gateway.platforms.whatsapp._IS_WINDOWS", True), \
|
|
patch("gateway.platforms.whatsapp.subprocess.run", side_effect=run_side_effect) as mock_run:
|
|
_kill_port_process(3000)
|
|
|
|
# netstat called
|
|
assert any(
|
|
call.args[0][0] == "netstat" for call in mock_run.call_args_list
|
|
)
|
|
# taskkill called with correct PID
|
|
assert any(
|
|
call.args[0] == ["taskkill", "/PID", "12345", "/F"]
|
|
for call in mock_run.call_args_list
|
|
)
|
|
|
|
def test_does_not_kill_wrong_port_on_windows(self):
|
|
from gateway.platforms.whatsapp import _kill_port_process
|
|
|
|
netstat_output = (
|
|
" TCP 0.0.0.0:30000 0.0.0.0:0 LISTENING 55555\n"
|
|
)
|
|
mock_netstat = MagicMock(stdout=netstat_output)
|
|
|
|
with patch("gateway.platforms.whatsapp._IS_WINDOWS", True), \
|
|
patch("gateway.platforms.whatsapp.subprocess.run", return_value=mock_netstat) as mock_run:
|
|
_kill_port_process(3000)
|
|
|
|
# Should NOT call taskkill because port 30000 != 3000
|
|
assert not any(
|
|
call.args[0][0] == "taskkill"
|
|
for call in mock_run.call_args_list
|
|
)
|
|
|
|
def test_uses_fuser_on_linux(self):
|
|
from gateway.platforms.whatsapp import _kill_port_process
|
|
|
|
mock_check = MagicMock(returncode=0)
|
|
|
|
with patch("gateway.platforms.whatsapp._IS_WINDOWS", False), \
|
|
patch("gateway.platforms.whatsapp.subprocess.run", return_value=mock_check) as mock_run:
|
|
_kill_port_process(3000)
|
|
|
|
calls = [c.args[0] for c in mock_run.call_args_list]
|
|
assert ["fuser", "3000/tcp"] in calls
|
|
assert ["fuser", "-k", "3000/tcp"] in calls
|
|
|
|
def test_skips_fuser_kill_when_port_free(self):
|
|
from gateway.platforms.whatsapp import _kill_port_process
|
|
|
|
mock_check = MagicMock(returncode=1) # port not in use
|
|
|
|
with patch("gateway.platforms.whatsapp._IS_WINDOWS", False), \
|
|
patch("gateway.platforms.whatsapp.subprocess.run", return_value=mock_check) as mock_run:
|
|
_kill_port_process(3000)
|
|
|
|
calls = [c.args[0] for c in mock_run.call_args_list]
|
|
assert ["fuser", "3000/tcp"] in calls
|
|
assert ["fuser", "-k", "3000/tcp"] not in calls
|
|
|
|
def test_suppresses_exceptions(self):
|
|
from gateway.platforms.whatsapp import _kill_port_process
|
|
|
|
with patch("gateway.platforms.whatsapp._IS_WINDOWS", True), \
|
|
patch("gateway.platforms.whatsapp.subprocess.run", side_effect=OSError("no netstat")):
|
|
_kill_port_process(3000) # must not raise
|