浏览器自动恢复逻辑
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"""浏览器池 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 "
|
||||
"<WriteUnixTransport closed=True reading=False 0x7f9a27185a40>; 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]
|
||||
Reference in New Issue
Block a user