Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
"""Tests for #85125 Phase 3b (browser) + 4c: suspect-session recycle after a
|
||||
command timeout (#72205, salvaging #72206) and daemon tree-kill on the wedged
|
||||
path (#68139).
|
||||
|
||||
All tests use a fake daemon layer (mocked Popen + monkeypatched probes) —
|
||||
no real agent-browser or Chromium is spawned.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.browser_tool as bt
|
||||
|
||||
TASK = "suspect-task"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_browser_state():
|
||||
def _clear():
|
||||
bt._active_sessions.clear()
|
||||
bt._session_last_activity.clear()
|
||||
bt._last_active_session_key.clear()
|
||||
bt._suspect_browser_sessions.clear()
|
||||
|
||||
_clear()
|
||||
yield
|
||||
_clear()
|
||||
|
||||
|
||||
def _local_session(name="stuck-session"):
|
||||
return {"session_name": name, "bb_session_id": None, "cdp_url": None}
|
||||
|
||||
|
||||
def _install_command_stubs(monkeypatch, tmp_path, process):
|
||||
"""Common _run_browser_command environment with a fake daemon layer."""
|
||||
monkeypatch.setattr(bt, "_find_agent_browser", lambda: "agent-browser")
|
||||
monkeypatch.setattr(bt, "_requires_real_termux_browser_install", lambda _cmd: False)
|
||||
monkeypatch.setattr(bt, "_chromium_installed", lambda: True)
|
||||
monkeypatch.setattr(bt, "_start_browser_cleanup_thread", lambda: None)
|
||||
monkeypatch.setattr(bt, "_ensure_cdp_supervisor", lambda _tid: None)
|
||||
monkeypatch.setattr(bt, "_stop_cdp_supervisor", lambda _tid: None)
|
||||
monkeypatch.setattr(bt, "_socket_safe_tmpdir", lambda: str(tmp_path))
|
||||
monkeypatch.setattr(bt, "_write_owner_pid", lambda *_args: None)
|
||||
monkeypatch.setattr(bt, "_build_browser_env", lambda: {})
|
||||
monkeypatch.setattr(bt, "_merge_browser_path", lambda value: value)
|
||||
monkeypatch.setattr(bt, "_get_browser_engine", lambda: "auto")
|
||||
monkeypatch.setattr(bt, "_is_headed_mode", lambda: False)
|
||||
monkeypatch.setattr(subprocess, "Popen", lambda *_a, **_k: process)
|
||||
monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False)
|
||||
|
||||
|
||||
class TestTimeoutMarksSuspect:
|
||||
def test_timeout_with_alive_daemon_marks_suspect_exactly_once(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
"""Alive-daemon branch: session stays cached, flagged suspect once."""
|
||||
session_info = _local_session()
|
||||
bt._active_sessions[TASK] = session_info
|
||||
|
||||
process = Mock()
|
||||
process.returncode = -9
|
||||
process.wait.side_effect = [subprocess.TimeoutExpired("agent-browser", 1), -9]
|
||||
_install_command_stubs(monkeypatch, tmp_path, process)
|
||||
|
||||
# Daemon alive + responsive → recycle-at-next-use branch, no kill.
|
||||
monkeypatch.setattr(bt, "_read_browser_daemon_pid", lambda *_a: 4321)
|
||||
monkeypatch.setattr(bt, "_pid_exists", lambda _pid: True)
|
||||
monkeypatch.setattr(bt, "_verify_reapable_browser_daemon", lambda *_a: True)
|
||||
monkeypatch.setattr(bt, "_browser_daemon_responsive", lambda *_a, **_k: True)
|
||||
kills = []
|
||||
monkeypatch.setattr("agent.deadline.kill_process_tree", lambda pid, **_k: kills.append(pid))
|
||||
|
||||
marks = []
|
||||
original_mark = bt._BrowserSessionBackend.mark_suspect
|
||||
|
||||
def counting_mark(self, reason):
|
||||
marks.append(reason)
|
||||
original_mark(self, reason)
|
||||
|
||||
monkeypatch.setattr(bt._BrowserSessionBackend, "mark_suspect", counting_mark)
|
||||
|
||||
result = bt._run_browser_command(TASK, "click", ["@e1"], timeout=1)
|
||||
|
||||
assert result["success"] is False
|
||||
assert len(marks) == 1 # marked suspect exactly once
|
||||
assert bt._suspect_browser_sessions == {
|
||||
TASK: "browser command timed out; session may be poisoned"
|
||||
}
|
||||
# Alive branch: session stays cached for the next-use recycle...
|
||||
assert bt._active_sessions[TASK] is session_info
|
||||
# ...and the daemon is NOT tree-killed.
|
||||
assert kills == []
|
||||
|
||||
|
||||
class TestNextUseRecycles:
|
||||
def test_next_call_after_suspect_recycles_then_succeeds(self, monkeypatch):
|
||||
stale = _local_session("stale-session")
|
||||
bt._active_sessions[TASK] = stale
|
||||
bt._suspect_browser_sessions[TASK] = "browser command timed out"
|
||||
|
||||
monkeypatch.setattr(bt, "_start_browser_cleanup_thread", lambda: None)
|
||||
monkeypatch.setattr(bt, "_get_cdp_override", lambda: "")
|
||||
monkeypatch.setattr(bt, "_get_cloud_provider", lambda: None)
|
||||
monkeypatch.setattr(bt, "_ensure_cdp_supervisor", lambda _tid: None)
|
||||
|
||||
cleanups = []
|
||||
|
||||
def fake_cleanup(task_id):
|
||||
cleanups.append(task_id)
|
||||
with bt._cleanup_lock:
|
||||
bt._active_sessions.pop(task_id, None)
|
||||
bt._session_last_activity.pop(task_id, None)
|
||||
|
||||
monkeypatch.setattr(bt, "_cleanup_single_browser_session", fake_cleanup)
|
||||
fresh = {"session_name": "fresh-session"}
|
||||
monkeypatch.setattr(bt, "_create_local_session", lambda _tid: dict(fresh))
|
||||
|
||||
session = bt._get_session_info(TASK)
|
||||
|
||||
assert cleanups == [TASK] # suspect session recycled exactly once
|
||||
assert session["session_name"] == "fresh-session"
|
||||
assert bt._active_sessions[TASK] is session
|
||||
assert TASK not in bt._suspect_browser_sessions # flag consumed
|
||||
|
||||
# A second call reuses the fresh session without another recycle.
|
||||
again = bt._get_session_info(TASK)
|
||||
assert again is session
|
||||
assert cleanups == [TASK]
|
||||
|
||||
def test_ensure_healthy_true_without_suspect_flag(self, monkeypatch):
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
bt, "_cleanup_single_browser_session", lambda t: called.append(t)
|
||||
)
|
||||
assert bt._browser_session_backend(TASK).ensure_healthy() is True
|
||||
assert called == []
|
||||
|
||||
|
||||
class TestSuccessfulCallNeverRecycles:
|
||||
def test_successful_command_does_not_recycle_or_mark(self, monkeypatch, tmp_path):
|
||||
"""REQUIRED negative probe: success must not touch the cached session."""
|
||||
session_info = _local_session("healthy-session")
|
||||
bt._active_sessions[TASK] = session_info
|
||||
|
||||
payload = json.dumps({"success": True, "data": {"ok": 1}}).encode()
|
||||
|
||||
class FakePopen:
|
||||
returncode = 0
|
||||
|
||||
def __init__(self, *_args, **kwargs):
|
||||
os.write(kwargs["stdout"], payload)
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", FakePopen)
|
||||
process = None # FakePopen installed above; stub the rest.
|
||||
_install_command_stubs(monkeypatch, tmp_path, process)
|
||||
monkeypatch.setattr(subprocess, "Popen", FakePopen) # re-assert after stubs
|
||||
|
||||
recycle_calls = []
|
||||
monkeypatch.setattr(
|
||||
bt, "_cleanup_single_browser_session",
|
||||
lambda t: recycle_calls.append(t),
|
||||
)
|
||||
discard_calls = []
|
||||
monkeypatch.setattr(
|
||||
bt, "_discard_timed_out_browser_session",
|
||||
lambda *a: discard_calls.append(a),
|
||||
)
|
||||
kills = []
|
||||
monkeypatch.setattr("agent.deadline.kill_process_tree", lambda pid, **_k: kills.append(pid))
|
||||
|
||||
result = bt._run_browser_command(TASK, "click", ["@e1"], timeout=5)
|
||||
|
||||
assert result == {"success": True, "data": {"ok": 1}}
|
||||
assert bt._active_sessions[TASK] is session_info # cache untouched
|
||||
assert bt._suspect_browser_sessions == {} # never marked suspect
|
||||
assert recycle_calls == [] # never recycled
|
||||
assert discard_calls == []
|
||||
assert kills == []
|
||||
|
||||
|
||||
class TestWedgedDaemonTreeKill:
|
||||
def test_wedged_daemon_is_tree_killed_and_session_evicted(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
session_info = _local_session("wedged-session")
|
||||
bt._active_sessions[TASK] = session_info
|
||||
bt._session_last_activity[TASK] = 1.0
|
||||
bt._last_active_session_key[TASK] = TASK
|
||||
|
||||
daemon_pid = 5150
|
||||
socket_dir = tmp_path / "agent-browser-wedged-session"
|
||||
socket_dir.mkdir()
|
||||
(socket_dir / "wedged-session.pid").write_text(str(daemon_pid))
|
||||
|
||||
process = Mock()
|
||||
process.returncode = -9
|
||||
process.wait.side_effect = [subprocess.TimeoutExpired("agent-browser", 1), -9]
|
||||
_install_command_stubs(monkeypatch, tmp_path, process)
|
||||
|
||||
# Wedged: daemon PID exists but the control socket is unresponsive.
|
||||
monkeypatch.setattr(bt, "_pid_exists", lambda _pid: True)
|
||||
monkeypatch.setattr(bt, "_verify_reapable_browser_daemon", lambda *_a: True)
|
||||
monkeypatch.setattr(bt, "_browser_daemon_responsive", lambda *_a, **_k: False)
|
||||
|
||||
kills = []
|
||||
monkeypatch.setattr(
|
||||
"agent.deadline.kill_process_tree",
|
||||
lambda pid, **_k: kills.append(pid) or True,
|
||||
)
|
||||
|
||||
result = bt._run_browser_command(TASK, "click", ["@e1"], timeout=1)
|
||||
|
||||
assert result["success"] is False
|
||||
assert kills == [daemon_pid] # tree-kill hit the daemon PID
|
||||
assert TASK not in bt._active_sessions # evicted now, not at next use
|
||||
assert TASK not in bt._session_last_activity
|
||||
assert TASK not in bt._last_active_session_key
|
||||
assert not socket_dir.exists() # socket dir reclaimed
|
||||
|
||||
def test_dead_daemon_skips_kill_but_still_evicts(self, monkeypatch, tmp_path):
|
||||
"""No PID file → nothing to kill, but the session is still discarded."""
|
||||
session_info = _local_session("dead-session")
|
||||
bt._active_sessions[TASK] = session_info
|
||||
socket_dir = str(tmp_path / "agent-browser-dead-session")
|
||||
os.makedirs(socket_dir)
|
||||
|
||||
kills = []
|
||||
monkeypatch.setattr(
|
||||
"agent.deadline.kill_process_tree",
|
||||
lambda pid, **_k: kills.append(pid) or True,
|
||||
)
|
||||
monkeypatch.setattr(bt, "_stop_cdp_supervisor", lambda _tid: None)
|
||||
|
||||
bt._handle_browser_command_timeout(TASK, session_info, socket_dir)
|
||||
|
||||
assert kills == []
|
||||
assert TASK not in bt._active_sessions
|
||||
# The eviction already removed the poisoned entry, so the flag is
|
||||
# dropped too — it must not poison a later session under this key.
|
||||
assert TASK not in bt._suspect_browser_sessions
|
||||
|
||||
|
||||
class TestFreshSessionClearsStaleFlag:
|
||||
def test_new_session_creation_drops_stale_suspect_flag(self, monkeypatch):
|
||||
"""Wedged path evicts + flags; the fresh session must not inherit it."""
|
||||
bt._suspect_browser_sessions[TASK] = "stale reason"
|
||||
|
||||
monkeypatch.setattr(bt, "_start_browser_cleanup_thread", lambda: None)
|
||||
monkeypatch.setattr(bt, "_get_cdp_override", lambda: "")
|
||||
monkeypatch.setattr(bt, "_get_cloud_provider", lambda: None)
|
||||
monkeypatch.setattr(bt, "_ensure_cdp_supervisor", lambda _tid: None)
|
||||
monkeypatch.setattr(
|
||||
bt, "_create_local_session", lambda _tid: {"session_name": "fresh"}
|
||||
)
|
||||
|
||||
session = bt._get_session_info(TASK)
|
||||
|
||||
assert session["session_name"] == "fresh"
|
||||
assert TASK not in bt._suspect_browser_sessions
|
||||
Reference in New Issue
Block a user