gendesign/tradein-mvp/browser/test_server_instance_limit.py
bot-backend 30d40e5747
All checks were successful
CI Trade-In / changes (pull_request) Successful in 19s
CI / changes (pull_request) Successful in 40s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 2m0s
CI Trade-In / backend-tests (pull_request) Successful in 8m5s
Сайдкар МЕРЫ: не больше трёх живых браузеров и никаких зомби-процессов (#3412)
Инстанс camoufox поднимался на поставщика и не закрывался никогда: пять
поставщиков давали пять вечных браузеров в контейнере с mem_limit 2560m. Прод
15.09-17.09: 5 живых простояли 2.4 ч прямо перед резким рестартом 16.09 19:08;
медиана working_set 1494 МиБ при 3 живых, 1956 при 4, 2108 при 5.

Потолок BROWSER_MAX_INSTANCES (по умолчанию 3) проверяется в _launch_browser,
через который идут все запуски, включая фоновый retry. На потолке закрывается
простаивающий инстанс другого поставщика: без context'а первым, по давности
использования внутри класса. Занятый лок не трогается, если закрыть некого,
запуск идёт сверх потолка с предупреждением. Слот резервируется на время
запуска. _close_browser снимает инстанс со словарей до первого await и больше
не сбрасывает пейсинг: это делают только собственный relaunch и смена прокси,
как раньше. Четыре дефекта ревью ветки fix/3412-cian-instance-relaunch
закрыты тестами, каждый краснеет при снятии своей части фикса.

init: true у сервиса browser: PID 1 был python server.py и не собирал
осиротевших детей закрытого браузера; прод 17.09 — 2296 процессов в Z за 14 ч.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 15:04:34 +05:00

209 lines
7.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""test_server_instance_limit.py — потолок живых camoufox и выбор жертвы (#3412).
До #3412 инстанс поставщика поднимался лениво и не закрывался никогда: пять
поставщиков = пять вечных браузеров в контейнере с mem_limit 2560m. Каждый тест
ниже закрывает один дефект ревью прошлой попытки (ветка fix/3412-cian-instance-
relaunch) или сам потолок; проверка — по тому, какие инстансы ОСТАЛИСЬ живыми.
camoufox не запускается: модуль camoufox.async_api подменён заглушкой.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_instance_limit.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
import sys
import types
from pathlib import Path
import pytest
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
class _FakeCM:
"""AsyncCamoufox-заглушка: запуск по желанию медленный, закрытие пишет журнал."""
launch_delay = 0.0
closed: list[str] = []
# provider → был ли он ещё в server._browsers в момент СВОЕГО закрытия
visible_while_closing: dict[str, bool] = {}
def __init__(self, **kwargs: object) -> None:
pass
async def __aenter__(self) -> object:
await asyncio.sleep(_FakeCM.launch_delay)
return object()
async def __aexit__(self, *args: object) -> None:
provider = next(p for p, cm in _cms_by_provider.items() if cm is self)
_FakeCM.visible_while_closing[provider] = provider in server._browsers
await asyncio.sleep(0)
_FakeCM.closed.append(provider)
_cms_by_provider: dict[str, _FakeCM] = {}
@pytest.fixture(autouse=True)
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
for name in ("_browsers", "_browser_cms", "_contexts", "_anchor_pages", "_anchor_origins",
"_page_counters", "_locks", "_retry_tasks", "_last_goto_at",
"_launched_proxy", "_last_used"):
monkeypatch.setattr(server, name, {})
monkeypatch.setattr(server, "_launching", set())
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
monkeypatch.setattr(server, "BROWSER_MAX_INSTANCES", 2)
_FakeCM.launch_delay = 0.0
_FakeCM.closed = []
_FakeCM.visible_while_closing = {}
_cms_by_provider.clear()
fake_mod = types.ModuleType("camoufox.async_api")
fake_mod.AsyncCamoufox = _FakeCM # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "camoufox", types.ModuleType("camoufox"))
monkeypatch.setitem(sys.modules, "camoufox.async_api", fake_mod)
async def _launch(provider: str) -> None:
await server._launch_browser(provider, proxy_override="http://u:p@pool:8080")
_cms_by_provider[provider] = server._browser_cms[provider]
def _live() -> set[str]:
return set(server._browsers)
def test_launch_at_cap_closes_idle_instance() -> None:
async def _run() -> None:
await _launch("cian")
await _launch("yandex")
await _launch("generic")
asyncio.run(_run())
assert _live() == {"yandex", "generic"}
assert _FakeCM.closed == ["cian"]
def test_victim_is_cheap_instance_not_avito_with_anchor() -> None:
"""Дефект ревью 1: LRU закрывал avito с якорной вкладкой, оставляя cian без context'а."""
async def _run() -> None:
await _launch("avito") # использован ДАВНЕЕ всех, но с прогретым context'ом
server._contexts["avito"] = types.SimpleNamespace(close=_noop)
server._anchor_pages["avito"] = object()
await _launch("cian")
await _launch("yandex")
asyncio.run(_run())
assert _live() == {"avito", "yandex"}
assert _FakeCM.closed == ["cian"]
async def _noop() -> None:
return None
def test_eviction_keeps_victim_pacing() -> None:
"""Дефект ревью 2: вытеснение сбрасывало _last_goto_at чужого поставщика."""
async def _run() -> None:
await _launch("cian")
server._last_goto_at["cian"] = 12345.0
await _launch("yandex")
await _launch("generic")
asyncio.run(_run())
assert "cian" not in _live()
assert server._last_goto_at.get("cian") == 12345.0
def test_background_retry_path_respects_cap() -> None:
"""Дефект ревью 3: retry-путь _try_launch_browser поднимал инстанс мимо потолка."""
async def _run() -> bool:
await _launch("cian")
await _launch("yandex")
return await server._try_launch_browser("avito")
assert asyncio.run(_run()) is True
assert len(_live()) == 2
assert "avito" in _live()
def test_fresh_instance_is_not_the_next_victim() -> None:
"""Дефект ревью 4: свежий инстанс без отметки использования уходил первым."""
async def _run() -> None:
await _launch("cian")
assert await server._ensure_browser("cian", proxy_override="http://u:p@pool:8080")
await _launch("yandex") # свежий, после последнего обращения к cian
await _launch("generic") # должен закрыть давний cian, а не свежий yandex
asyncio.run(_run())
assert _FakeCM.closed == ["cian"]
def test_use_refreshes_lru_order() -> None:
async def _run() -> None:
await _launch("cian")
await _launch("yandex")
assert await server._ensure_browser("cian", proxy_override="http://u:p@pool:8080")
await _launch("generic")
asyncio.run(_run())
assert _FakeCM.closed == ["yandex"]
def test_busy_instance_is_never_closed() -> None:
async def _run() -> None:
await _launch("cian")
await _launch("yandex")
for provider in ("cian", "yandex"):
await (await server._lock_for(provider)).acquire()
await _launch("generic")
asyncio.run(_run())
assert _FakeCM.closed == []
assert _live() == {"cian", "yandex", "generic"}
def test_parallel_launches_reserve_their_slot() -> None:
"""Два запуска разных поставщиков одновременно не должны оба увидеть свободное место."""
async def _run() -> None:
await _launch("cian")
_FakeCM.launch_delay = 0.01
await asyncio.gather(_launch("avito"), _launch("yandex"))
asyncio.run(_run())
assert _live() == {"avito", "yandex"}
def test_evicted_instance_disappears_before_close_awaits() -> None:
"""Жертву закрывает чужая корутина без её лока: во время close() браузер уже снят."""
async def _run() -> None:
await _launch("cian")
await _launch("yandex")
await _launch("generic")
asyncio.run(_run())
assert _FakeCM.visible_while_closing == {"cian": False}