Follow-up to the cherry-picked reconnect cap (#114178, @KoNit-K); supersedes the earlier #44460 (@plcunha), whose bounded-reconnect design this lands in slimmer form: - Factor the two give-up branches into ``CDPSupervisor._reconnect_budget_spent``, which logs the single final warning AND drops the supervisor from ``SUPERVISOR_REGISTRY`` when it is still the registered one. Consumers (``browser_cdp`` frame routing, the eval fast path) then see "no supervisor attached" instead of a dead entry, and the next browser call for the task starts a fresh one via ``get_or_start``. - Move the unit tests to ``tests/tools/test_browser_supervisor_reconnect.py`` (two invariants: bounded + unregistered after attach; first-dial failure still fatal for ``start()``) and restore ``test_browser_supervisor.py`` to main — that file is the opt-in real-Chrome E2E suite and its module-level skip marker did not need to be rewritten to host unit tests. - Docs: lifecycle section of the developer guide describes the bounded reconnect. Live: fake CDP endpoint killed after attach — base logs connect-failed warnings indefinitely (7 in 45 s, thread alive, still registered); fixed head logs 4 + one "stopped after 5 failed reconnect attempts" line, thread exits, registry entry gone. Control: endpoint back within 2.5 s re-attaches and the budget resets. Co-authored-by: plcunha <jvsantos.cunha@gmail.com>
82 lines
2.9 KiB
Python
82 lines
2.9 KiB
Python
"""CDPSupervisor reconnect budget: a supervisor that attached once must not retry a dead
|
|
endpoint forever (#114172). No real Chrome — ``websockets.connect`` is stubbed."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
import websockets
|
|
|
|
from tools import browser_supervisor as bs
|
|
|
|
|
|
class _ClosingWebSocket:
|
|
async def close(self):
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def unregister():
|
|
task_ids: list[str] = []
|
|
yield task_ids
|
|
for task_id in task_ids:
|
|
bs.SUPERVISOR_REGISTRY._pop(task_id)
|
|
|
|
|
|
def test_post_attach_reconnects_stop_at_budget_and_unregister(monkeypatch, unregister, caplog):
|
|
"""After the first attach, a dead endpoint gets MAX_POST_ATTACH_RECONNECT_FAILURES dials,
|
|
one final warning, and the supervisor leaves the registry — never an unbounded loop."""
|
|
supervisor = bs.CDPSupervisor(task_id="bounded-reconnect", cdp_url="ws://127.0.0.1:9222")
|
|
bs.SUPERVISOR_REGISTRY._by_task[supervisor.task_id] = supervisor
|
|
unregister.append(supervisor.task_id)
|
|
budget = bs.MAX_POST_ATTACH_RECONNECT_FAILURES
|
|
dials = 0
|
|
|
|
async def connect(*_args, **_kwargs):
|
|
nonlocal dials
|
|
dials += 1
|
|
if dials == 1:
|
|
return _ClosingWebSocket()
|
|
if dials <= budget + 1:
|
|
raise ConnectionError("CDP endpoint is gone")
|
|
await asyncio.Event().wait() # would hang forever: the loop must never get here
|
|
|
|
async def _noop(*_a, **_k):
|
|
pass
|
|
|
|
real_sleep = asyncio.sleep
|
|
|
|
async def fast_sleep(_delay):
|
|
await real_sleep(0) # yield so wait_for's deadline can fire on an unbounded loop
|
|
|
|
monkeypatch.setattr(websockets, "connect", connect)
|
|
monkeypatch.setattr(supervisor, "_attach_initial_page", _noop)
|
|
monkeypatch.setattr(supervisor, "_read_loop", _noop)
|
|
monkeypatch.setattr(bs.asyncio, "sleep", fast_sleep)
|
|
|
|
with caplog.at_level("WARNING", logger="tools.browser_supervisor"):
|
|
asyncio.run(asyncio.wait_for(supervisor._run(), timeout=2.0))
|
|
|
|
assert dials == budget + 1 # attach + budgeted reconnects
|
|
final = [r.getMessage() for r in caplog.records if "stopped after" in r.getMessage()]
|
|
assert len(final) == 1 and f"{budget} failed reconnect" in final[0]
|
|
assert supervisor.snapshot().active is False
|
|
assert bs.SUPERVISOR_REGISTRY.get(supervisor.task_id) is None
|
|
|
|
|
|
def test_initial_connect_failure_stays_fatal_for_start(monkeypatch):
|
|
"""The budget is post-attach only: a first-dial failure still propagates to ``start()``."""
|
|
supervisor = bs.CDPSupervisor(task_id="initial-failure", cdp_url="ws://127.0.0.1:9222")
|
|
|
|
async def connect(*_args, **_kwargs):
|
|
raise ConnectionError("CDP endpoint is unavailable")
|
|
|
|
monkeypatch.setattr(websockets, "connect", connect)
|
|
|
|
asyncio.run(supervisor._run())
|
|
|
|
assert isinstance(supervisor._start_error, ConnectionError)
|
|
assert supervisor._ready_event.is_set()
|
|
assert supervisor.snapshot().active is False
|