diff --git a/tradein-mvp/backend/tests/test_3412_browser_init_reaps_zombies.py b/tradein-mvp/backend/tests/test_3412_browser_init_reaps_zombies.py new file mode 100644 index 00000000..3c6eda6f --- /dev/null +++ b/tradein-mvp/backend/tests/test_3412_browser_init_reaps_zombies.py @@ -0,0 +1,24 @@ +"""#3412: у tradein-browser PID 1 — init, который собирает осиротевших детей camoufox. + +Без `init: true` PID 1 контейнера — `python server.py`; дочерние процессы закрытого +браузера переходят к нему и остаются зомби (прод 17.09.2026: 2296 процессов в Z за 14 ч). +Проверка по значению: разобранный compose, сервис с образом tradein-browser. +""" + +from pathlib import Path + +import yaml + +_COMPOSE = Path(__file__).resolve().parents[2] / "docker-compose.prod.yml" + + +def test_browser_service_runs_under_init() -> None: + services = yaml.safe_load(_COMPOSE.read_text())["services"] + browser = { + name: svc + for name, svc in services.items() + if "gendesign-tradein-browser" in svc.get("image", "") + } + # Контроль разбора: пустой словарь дал бы зелёный тест ни о чём. + assert set(browser) == {"browser"}, sorted(browser) + assert browser["browser"].get("init") is True diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 4cbc3a8c..0f52a9ff 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -34,6 +34,9 @@ Per-provider модель (#1793): Переменные окружения: BROWSER_PORT — TCP-порт HTTP-сервера (default: 3000) + BROWSER_MAX_INSTANCES — потолок одновременно живых camoufox на весь сайдкар + (default: 3, #3412). Перед запуском нового инстанса + закрывается простаивающий чужой; занятый — никогда. BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска, глобальный дефолт для провайдеров без код-дефолта (default: 15). per-provider код-дефолт см. BROWSER_RECYCLE_PAGES_{PROVIDER}. @@ -141,6 +144,7 @@ Per-provider модель (#1793): import asyncio import base64 import gzip +import itertools import logging import os import random @@ -164,6 +168,14 @@ ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "dev") IS_PROD: bool = ENVIRONMENT == "production" BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000")) +# #3412: до потолка инстанс поднимался лениво на поставщика и не закрывался никогда — +# пять поставщиков давали пять вечных camoufox в контейнере с mem_limit 2560m. +# Откуда 3: cadvisor working_set tradein-browser за 41 ч (15.09 16:55 → 17.09 09:53), +# сопоставленный с числом живых инстансов по логу запусков/закрытий: медиана 1311 МиБ +# при 2 живых, 1494 при 3, 1956 при 4, 2108 при 5; 5 живых простояли 2.4 ч прямо перед +# резким рестартом контейнера 16.09 19:08. Потолок не тормозит работу: занятый инстанс +# не закрывается, лишний запуск идёт сверх потолка (см. _trim_instances). +BROWSER_MAX_INSTANCES: int = max(1, int(os.environ.get("BROWSER_MAX_INSTANCES", "3"))) # Глобальной константы recycle больше нет (#3205): порог стал поставщик-зависимым и # живёт в _RECYCLE_PAGES_BY_PROVIDER. Env BROWSER_RECYCLE_PAGES по-прежнему читается — # внутри _resolve_recycle_pages, как один из уровней приоритета. Константу убрали @@ -849,6 +861,14 @@ _last_nav_ms: dict[str, int | None] = {} # смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим, # если запрошенный proxy совпадает с текущим. None = запущен без прокси (прямое). _launched_proxy: dict[str, str | None] = {} +# #3412: поставщики, чей camoufox поднимается прямо сейчас. Считаются живыми для +# потолка: запуск длится секунды, и два параллельных запуска иначе оба увидели бы +# свободное место. +_launching: set[str] = set() +# #3412: provider → номер последнего использования (LRU выбора жертвы). Счётчик, а не +# часы: два обращения в один тик часов неразличимы. +_last_used: dict[str, int] = {} +_use_seq = itertools.count(1) # Guard на ленивое создание per-provider локов: setdefault на обычном dict из # разных корутин гонок не даёт (нет await между read-modify-write), но держим @@ -927,12 +947,20 @@ async def _launch_browser(provider: str, proxy_override: str | None = None) -> N proxy is not None, _RECYCLE_PAGES_BY_PROVIDER.get(provider, _RECYCLE_PAGES_DEFAULT_FALLBACK), ) - cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type] - browser = await cm.__aenter__() + # #3412: потолок живых — здесь, а не в _ensure_browser: сюда сходятся ВСЕ пути + # запуска (первый запрос, смена прокси, recycle/краш, фоновый retry). + await _trim_instances(provider) + _launching.add(provider) + try: + cm = AsyncCamoufox(**kwargs) # type: ignore[arg-type] + browser = await cm.__aenter__() + finally: + _launching.discard(provider) _browser_cms[provider] = cm _browsers[provider] = browser _page_counters[provider] = 0 _launched_proxy[provider] = proxy_url + _last_used[provider] = next(_use_seq) # свежий инстанс — не первая жертва LRU logger.info( "tradein-browser[%s]: браузер запущен (proxy_override=%s)", provider, @@ -1050,9 +1078,22 @@ async def _close_reusable_context(provider: str) -> None: async def _close_browser(provider: str) -> None: - """Закрывает per-provider браузер + переиспользуемый context (если есть, #3118).""" - await _close_reusable_context(provider) - cm = _browser_cms.get(provider) + """Закрывает per-provider браузер + переиспользуемый context (если есть, #3118). + + Инстанс снимается со словарей ДО первого await (#3412): закрыть его может и чужой + запуск по потолку (_trim_instances), не держащий _locks[provider]. Запрос этого + поставщика, вошедший под лок во время close(), должен увидеть «инстанса нет» и + поднять свой, а не получить полузакрытый браузер. + + _last_goto_at НЕ трогает: пейсинг — ритм обращений к площадке, он переживает + инстанс. Сбрасывают его те, кто перезапускает инстанс сам (_relaunch_browser, + смена прокси в _ensure_browser), — ровно как было до #3412. + """ + cm = _browser_cms.pop(provider, None) + _browsers.pop(provider, None) + _page_counters[provider] = 0 + _launched_proxy.pop(provider, None) + await _close_reusable_context(provider) # context/якорь снимает до своего await if cm is not None: try: await cm.__aexit__(None, None, None) # type: ignore[attr-defined] @@ -1063,11 +1104,56 @@ async def _close_browser(provider: str) -> None: provider, type(exc).__name__, ) - _browsers.pop(provider, None) - _browser_cms.pop(provider, None) - _page_counters[provider] = 0 - _last_goto_at.pop(provider, None) - _launched_proxy.pop(provider, None) + + +def _pick_victim(provider: str) -> str | None: + """Чей простаивающий инстанс закрыть под новый инстанс ``provider`` (#3412). + + Кандидаты — живые инстансы ЧУЖИХ поставщиков со свободным _locks: вся работа + поставщика идёт под его локом, занятый лок = страница в работе. Первыми идут + инстансы без context'а и якорной вкладки: их перезапуск стоит только процесса. + С context'ом (avito/domclick) теряются прогретые куки площадки и вкладка выдачи — + 4-5 с на карточку против 17-52 с без неё (замер 29.08, см. _anchor_pages). Внутри + класса — давнее всех использованный. None — закрыть некого. + """ + idle = [ + p + for p in _browsers + if p != provider and not (p in _locks and _locks[p].locked()) + ] + if not idle: + return None + return min(idle, key=lambda p: (p in _contexts or p in _anchor_pages, _last_used.get(p, 0))) + + +async def _trim_instances(provider: str) -> None: + """Освобождает место под новый инстанс ``provider``, пока живых >= потолка (#3412). + + ponytail: потолок мягкий — если все прочие инстансы заняты, поднимаемся сверх него + с предупреждением, а не отказываем запросу и не рвём чужую навигацию. Очередь на + слот — если предупреждение станет регулярным. Цена мягкости и места проверки: запуск, + который потом упадёт (InvalidIP), успевает закрыть один простаивающий инстанс. + """ + while len(_browsers.keys() | _launching) >= BROWSER_MAX_INSTANCES: + victim = _pick_victim(provider) + if victim is None: + logger.warning( + "tradein-browser[%s]: живых инстансов %d при потолке %d, все заняты — " + "запуск сверх потолка", + provider, + len(_browsers.keys() | _launching), + BROWSER_MAX_INSTANCES, + ) + return + logger.info( + "tradein-browser[%s]: живых инстансов %d при потолке %d — закрываем " + "простаивающий %s", + provider, + len(_browsers.keys() | _launching), + BROWSER_MAX_INSTANCES, + victim, + ) + await _close_browser(victim) async def _relaunch_browser(provider: str, proxy_override: str | None = None) -> None: @@ -1083,6 +1169,7 @@ async def _relaunch_browser(provider: str, proxy_override: str | None = None) -> effective = proxy_override if proxy_override is not None else _launched_proxy.get(provider) logger.info("tradein-browser[%s]: перезапуск браузера", provider) await _close_browser(provider) + _last_goto_at.pop(provider, None) await _launch_browser(provider, proxy_override=effective) @@ -1137,6 +1224,7 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b desired_proxy = proxy_override if proxy_override is not None else _provider_proxy(provider) if _browsers.get(provider) is not None: if _launched_proxy.get(provider) == desired_proxy: + _last_used[provider] = next(_use_seq) return True # Реальная смена прокси → пересобрать инстанс с новым прокси. logger.info( @@ -1145,6 +1233,7 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b proxy_override is not None, ) await _close_browser(provider) + _last_goto_at.pop(provider, None) await _try_launch_browser(provider, proxy_override=proxy_override) if _browsers.get(provider) is not None: @@ -1211,6 +1300,7 @@ async def _on_startup(app: web.Application) -> None: logger.info("tradein-browser: page-intervals %s", _MIN_PAGE_INTERVAL_BY_PROVIDER) logger.info("tradein-browser: block-images %s", _BLOCK_IMAGES_BY_PROVIDER) logger.info("tradein-browser: recycle-pages %s", _RECYCLE_PAGES_BY_PROVIDER) + logger.info("tradein-browser: потолок живых инстансов %d", BROWSER_MAX_INSTANCES) _warn_retired_env() diff --git a/tradein-mvp/browser/test_server_instance_limit.py b/tradein-mvp/browser/test_server_instance_limit.py new file mode 100644 index 00000000..46527f03 --- /dev/null +++ b/tradein-mvp/browser/test_server_instance_limit.py @@ -0,0 +1,209 @@ +"""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} diff --git a/tradein-mvp/docker-compose.prod.yml b/tradein-mvp/docker-compose.prod.yml index 5aa3ef03..897b986c 100644 --- a/tradein-mvp/docker-compose.prod.yml +++ b/tradein-mvp/docker-compose.prod.yml @@ -82,6 +82,12 @@ services: # (грубо ~2.5g на каждую доп. параллельную страницу). mem_limit: 2560m memswap_limit: 3g + # init (#3412): tini как PID 1. Без него PID 1 — `python server.py`, который не + # собирает осиротевших детей: дочерние процессы закрытого camoufox (forkserver, + # Utility, Socket, RDD) переходят к PID 1 и висят зомби навсегда. Прод 17.09.2026 + # за 14 ч с рестарта: 2296 процессов в состоянии Z (по 574 каждого вида), + # pids.current 2722 при трёх живых браузерах. + init: true # stop_grace_period: browser/server.py — bare aiohttp web.run_app(), которое # само ловит SIGTERM (aiohttp.web.GracefulExit) и даёт себе внутренний # shutdown_timeout=60s (aiohttp default, здесь не переопределён) на закрытие