159 lines
5.5 KiB
Python
159 lines
5.5 KiB
Python
|
|
"""Tests for _reap_orphaned_browser_sessions() — kills orphaned agent-browser
|
||
|
|
daemons whose Python parent exited without cleaning up."""
|
||
|
|
|
||
|
|
import os
|
||
|
|
import signal
|
||
|
|
import textwrap
|
||
|
|
from pathlib import Path
|
||
|
|
from unittest.mock import patch, MagicMock
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def fake_tmpdir(tmp_path):
|
||
|
|
"""Patch _socket_safe_tmpdir to return a temp dir we control."""
|
||
|
|
with patch("tools.browser_tool._socket_safe_tmpdir", return_value=str(tmp_path)):
|
||
|
|
yield tmp_path
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(autouse=True)
|
||
|
|
def _isolate_sessions():
|
||
|
|
"""Ensure _active_sessions is empty for each test."""
|
||
|
|
import tools.browser_tool as bt
|
||
|
|
orig = bt._active_sessions.copy()
|
||
|
|
bt._active_sessions.clear()
|
||
|
|
yield
|
||
|
|
bt._active_sessions.clear()
|
||
|
|
bt._active_sessions.update(orig)
|
||
|
|
|
||
|
|
|
||
|
|
def _make_socket_dir(tmpdir, session_name, pid=None):
|
||
|
|
"""Create a fake agent-browser socket directory with optional PID file."""
|
||
|
|
d = tmpdir / f"agent-browser-{session_name}"
|
||
|
|
d.mkdir()
|
||
|
|
if pid is not None:
|
||
|
|
(d / f"{session_name}.pid").write_text(str(pid))
|
||
|
|
return d
|
||
|
|
|
||
|
|
|
||
|
|
class TestReapOrphanedBrowserSessions:
|
||
|
|
"""Tests for the orphan reaper function."""
|
||
|
|
|
||
|
|
def test_no_socket_dirs_is_noop(self, fake_tmpdir):
|
||
|
|
"""No socket dirs => nothing happens, no errors."""
|
||
|
|
from tools.browser_tool import _reap_orphaned_browser_sessions
|
||
|
|
_reap_orphaned_browser_sessions() # should not raise
|
||
|
|
|
||
|
|
def test_stale_dir_without_pid_file_is_removed(self, fake_tmpdir):
|
||
|
|
"""Socket dir with no PID file is cleaned up."""
|
||
|
|
from tools.browser_tool import _reap_orphaned_browser_sessions
|
||
|
|
d = _make_socket_dir(fake_tmpdir, "h_abc1234567")
|
||
|
|
assert d.exists()
|
||
|
|
_reap_orphaned_browser_sessions()
|
||
|
|
assert not d.exists()
|
||
|
|
|
||
|
|
def test_stale_dir_with_dead_pid_is_removed(self, fake_tmpdir):
|
||
|
|
"""Socket dir whose daemon PID is dead gets cleaned up."""
|
||
|
|
from tools.browser_tool import _reap_orphaned_browser_sessions
|
||
|
|
d = _make_socket_dir(fake_tmpdir, "h_dead123456", pid=999999999)
|
||
|
|
assert d.exists()
|
||
|
|
_reap_orphaned_browser_sessions()
|
||
|
|
assert not d.exists()
|
||
|
|
|
||
|
|
def test_orphaned_alive_daemon_is_killed(self, fake_tmpdir):
|
||
|
|
"""Alive daemon not tracked by _active_sessions gets SIGTERM."""
|
||
|
|
from tools.browser_tool import _reap_orphaned_browser_sessions
|
||
|
|
|
||
|
|
d = _make_socket_dir(fake_tmpdir, "h_orphan12345", pid=12345)
|
||
|
|
|
||
|
|
kill_calls = []
|
||
|
|
original_kill = os.kill
|
||
|
|
|
||
|
|
def mock_kill(pid, sig):
|
||
|
|
kill_calls.append((pid, sig))
|
||
|
|
if sig == 0:
|
||
|
|
return # pretend process exists
|
||
|
|
# Don't actually kill anything
|
||
|
|
|
||
|
|
with patch("os.kill", side_effect=mock_kill):
|
||
|
|
_reap_orphaned_browser_sessions()
|
||
|
|
|
||
|
|
# Should have checked existence (sig 0) then killed (SIGTERM)
|
||
|
|
assert (12345, 0) in kill_calls
|
||
|
|
assert (12345, signal.SIGTERM) in kill_calls
|
||
|
|
|
||
|
|
def test_tracked_session_is_not_reaped(self, fake_tmpdir):
|
||
|
|
"""Sessions tracked in _active_sessions are left alone."""
|
||
|
|
import tools.browser_tool as bt
|
||
|
|
from tools.browser_tool import _reap_orphaned_browser_sessions
|
||
|
|
|
||
|
|
session_name = "h_tracked1234"
|
||
|
|
d = _make_socket_dir(fake_tmpdir, session_name, pid=12345)
|
||
|
|
|
||
|
|
# Register the session as actively tracked
|
||
|
|
bt._active_sessions["some_task"] = {"session_name": session_name}
|
||
|
|
|
||
|
|
kill_calls = []
|
||
|
|
|
||
|
|
def mock_kill(pid, sig):
|
||
|
|
kill_calls.append((pid, sig))
|
||
|
|
|
||
|
|
with patch("os.kill", side_effect=mock_kill):
|
||
|
|
_reap_orphaned_browser_sessions()
|
||
|
|
|
||
|
|
# Should NOT have tried to kill anything
|
||
|
|
assert len(kill_calls) == 0
|
||
|
|
# Dir should still exist
|
||
|
|
assert d.exists()
|
||
|
|
|
||
|
|
def test_permission_error_on_kill_check_skips(self, fake_tmpdir):
|
||
|
|
"""If we can't check the PID (PermissionError), skip it."""
|
||
|
|
from tools.browser_tool import _reap_orphaned_browser_sessions
|
||
|
|
|
||
|
|
d = _make_socket_dir(fake_tmpdir, "h_perm1234567", pid=12345)
|
||
|
|
|
||
|
|
def mock_kill(pid, sig):
|
||
|
|
if sig == 0:
|
||
|
|
raise PermissionError("not our process")
|
||
|
|
|
||
|
|
with patch("os.kill", side_effect=mock_kill):
|
||
|
|
_reap_orphaned_browser_sessions()
|
||
|
|
|
||
|
|
# Dir should still exist (we didn't touch someone else's process)
|
||
|
|
assert d.exists()
|
||
|
|
|
||
|
|
def test_cdp_sessions_are_also_reaped(self, fake_tmpdir):
|
||
|
|
"""CDP sessions (cdp_ prefix) are also scanned."""
|
||
|
|
from tools.browser_tool import _reap_orphaned_browser_sessions
|
||
|
|
|
||
|
|
d = _make_socket_dir(fake_tmpdir, "cdp_abc1234567")
|
||
|
|
assert d.exists()
|
||
|
|
_reap_orphaned_browser_sessions()
|
||
|
|
# No PID file → cleaned up
|
||
|
|
assert not d.exists()
|
||
|
|
|
||
|
|
def test_non_hermes_dirs_are_ignored(self, fake_tmpdir):
|
||
|
|
"""Socket dirs that don't match our naming pattern are left alone."""
|
||
|
|
from tools.browser_tool import _reap_orphaned_browser_sessions
|
||
|
|
|
||
|
|
# Create a dir that doesn't match h_* or cdp_* pattern
|
||
|
|
d = fake_tmpdir / "agent-browser-other_session"
|
||
|
|
d.mkdir()
|
||
|
|
(d / "other_session.pid").write_text("12345")
|
||
|
|
|
||
|
|
_reap_orphaned_browser_sessions()
|
||
|
|
|
||
|
|
# Should NOT be touched
|
||
|
|
assert d.exists()
|
||
|
|
|
||
|
|
def test_corrupt_pid_file_is_cleaned(self, fake_tmpdir):
|
||
|
|
"""PID file with non-integer content is cleaned up."""
|
||
|
|
from tools.browser_tool import _reap_orphaned_browser_sessions
|
||
|
|
|
||
|
|
d = _make_socket_dir(fake_tmpdir, "h_corrupt1234")
|
||
|
|
(d / "h_corrupt1234.pid").write_text("not-a-number")
|
||
|
|
|
||
|
|
_reap_orphaned_browser_sessions()
|
||
|
|
assert not d.exists()
|