"""浏览器池 driver 断链自动恢复逻辑的单元测试(不启动真实 Chromium)。 覆盖本次修复的核心: - is_disconnect_error 对 WriteUnixTransport "the handler is closed" 等断链错误的识别 - _run_with_reconnect 在断链后重建一次并重试成功 - _browser_generation 守卫:并发下他人已重建时不重复重建 """ from __future__ import annotations import pytest from app.core.config import Settings from app.services.browser_pool import BrowserPool class _FakeBrowser: def __init__(self, connected: bool = True): self._connected = connected def is_connected(self) -> bool: return self._connected def _make_pool(**overrides) -> BrowserPool: settings = Settings(**overrides) pool = BrowserPool(settings) pool._browser = _FakeBrowser() pool._started = True pool._browser_generation = 1 return pool # 复现线上崩溃日志里的真实报错文本 _HANDLER_CLOSED = ( "Stealth.hooked_new_context_async: unable to perform operation on " "; the handler is closed" ) def test_is_disconnect_error_recognizes_closed_transport(): assert BrowserPool.is_disconnect_error(RuntimeError(_HANDLER_CLOSED)) is True assert BrowserPool.is_disconnect_error(Exception("connection closed")) is True # 业务异常不应被误判为断链 assert BrowserPool.is_disconnect_error(ValueError("invalid selector")) is False @pytest.mark.asyncio async def test_run_with_reconnect_rebuilds_then_retries(monkeypatch): """首次因断链失败 → 重建一次 → 第二次成功。""" pool = _make_pool() rebuilt: list[str] = [] async def fake_restart(reason, *, observed_generation=None): rebuilt.append(reason) pool._browser_generation += 1 # 模拟 start() 自增代次 monkeypatch.setattr(pool, "_restart_browser", fake_restart) attempts = {"n": 0} async def factory(): attempts["n"] += 1 if attempts["n"] == 1: raise RuntimeError(_HANDLER_CLOSED) return "ok" result = await pool._run_with_reconnect(factory, "测试操作") assert result == "ok" assert attempts["n"] == 2 assert len(rebuilt) == 1 @pytest.mark.asyncio async def test_run_with_reconnect_gives_up_after_one_retry(monkeypatch): """连续两次断链:只重试一次,第二次仍失败则抛出。""" pool = _make_pool() async def fake_restart(reason, *, observed_generation=None): pool._browser_generation += 1 monkeypatch.setattr(pool, "_restart_browser", fake_restart) attempts = {"n": 0} async def factory(): attempts["n"] += 1 raise RuntimeError(_HANDLER_CLOSED) with pytest.raises(RuntimeError): await pool._run_with_reconnect(factory, "测试操作") assert attempts["n"] == 2 # 初次 + 一次重试 @pytest.mark.asyncio async def test_run_with_reconnect_passes_through_non_disconnect(monkeypatch): """非断链异常不触发重建,直接抛出。""" pool = _make_pool() rebuilt: list[str] = [] async def fake_restart(reason, *, observed_generation=None): rebuilt.append(reason) monkeypatch.setattr(pool, "_restart_browser", fake_restart) async def factory(): raise ValueError("业务错误") with pytest.raises(ValueError): await pool._run_with_reconnect(factory, "测试操作") assert rebuilt == [] @pytest.mark.asyncio async def test_restart_browser_skips_when_generation_changed(): """持锁后代次已变化(他人已重建)→ 直接返回,不重复重建。""" pool = _make_pool() closed: list[str] = [] async def fake_close(): closed.append("close") pool.close = fake_close # type: ignore[method-assign] # 调用方观察到的是旧代次,而当前代次已被他人推进 await pool._restart_browser("stale", observed_generation=pool._browser_generation - 1) assert closed == [] # 未触发关闭/重建 @pytest.mark.asyncio async def test_open_context_and_page_closes_context_on_page_failure(monkeypatch): """页面创建失败时关闭半开上下文,避免泄漏。""" pool = _make_pool() class _Ctx: def __init__(self): self.closed = False async def new_page(self): raise RuntimeError("new_page boom") ctx = _Ctx() async def fake_new_context_raw(storage_state): return ctx closed: list[object] = [] async def fake_close_context(c): closed.append(c) monkeypatch.setattr(pool, "_new_context_raw", fake_new_context_raw) monkeypatch.setattr(pool, "close_context", fake_close_context) with pytest.raises(RuntimeError, match="new_page boom"): await pool._open_context_and_page(None) assert closed == [ctx]