From 2ad2169cf53e594996d6e89920c4b26a91c2d6b8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 7 Sep 2026 17:22:09 +0500 Subject: [PATCH] =?UTF-8?q?fix(browser):=20=D0=BF=D1=83=D0=BB=20=D1=82?= =?UTF-8?q?=D1=91=D0=BF=D0=BB=D1=8B=D1=85=20=D0=B8=D0=BD=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=BD=D1=81=D0=BE=D0=B2=20camoufox=20=D0=BF=D0=BE=20=D0=B0?= =?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=B4=D0=B5=20+=20=D0=BF=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=BA=20=D0=B6=D0=B8=D0=B2=D1=8B=D1=85=20(#3412)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Смена аренды прокси больше не убивает живой браузер: инстанс паркуется под своей арендой и достаётся тёплым, когда эта аренда возвращается. Прокси у camoufox задаётся на launch (geoip=True вяжет фингерпринт к IP аренды), сменить его у живого браузера нельзя — поэтому пул, а не per-context proxy. Замер прода за 25 ч (06-07.09, docker logs tradein-browser) НЕ подтвердил гипотезу «relaunch перед каждым fetch»: 154 launch'а на ~2000 cian-фетчей, из них 104 — recycle-порог (то есть порог срабатывает), 49 — смена аренды. В час пиковой чехарды аренд (07.09 00:xx, 24 relaunch'а на 25 фетчей) доля отказов была 1/26 = 3.8% — НИЖЕ суточной 4.8%. Первым фетчем на свежем инстансе падают 19 ошибок из 99 (12.3% против 4.2% на прогретом), остальные 80 размазаны по 86 разным URL и всем часам — это тяжёлый хвост латентности Циана, отдельная задача. Что замер подтвердил: инстансов никто не считал. Инстанс поднимался лениво на поставщика и не закрывался никогда, поэтому пять поставщиков давали пять вечных camoufox (~425 МиБ каждый) в контейнере с mem_limit 2560m. Cgroup за те же 25 ч: memory.events max=40781, oom_kill=111, sock_throttled=22177. Поэтому пул идёт вместе с потолком BROWSER_MAX_INSTANCES (default 4) и LRU-закрытием: пиковое число браузеров теперь МЕНЬШЕ прежнего, а не больше. Гонок не добавляет: снятие/установка инстанса синхронны (нет await между dict-операциями), жертву берём только среди припаркованных и активных инстансов со свободным _locks[provider] — занятый лок значит «страница in-flight». Новых локов нет. Попутно: BROWSER_CONCURRENCY внесена в _RETIRED_ENV — её задаёт compose, её цитируют оба обоснования потолка памяти, а код её не читает; параллелизм задаёт _locks[provider] (≤1 страница на поставщика, но поставщиков пять). --- tradein-mvp/browser/server.py | 312 +++++++++++++++++- .../browser/test_server_instance_pool.py | 281 ++++++++++++++++ 2 files changed, 577 insertions(+), 16 deletions(-) create mode 100644 tradein-mvp/browser/test_server_instance_pool.py diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 71de6501..617cd14c 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: 4, #3412). + Потолок памяти, а не производительности — см. константу. BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска, глобальный дефолт для провайдеров без код-дефолта (default: 15). per-provider код-дефолт см. BROWSER_RECYCLE_PAGES_{PROVIDER}. @@ -161,6 +164,20 @@ ENVIRONMENT: str = os.environ.get("ENVIRONMENT", "dev") IS_PROD: bool = ENVIRONMENT == "production" BROWSER_PORT: int = int(os.environ.get("BROWSER_PORT", "3000")) +# Потолок ОДНОВРЕМЕННО живых camoufox-инстансов на весь сайдкар (#3412): активные +# (_browsers) + припаркованные в пуле (_parked). До #3412 потолка не было вообще — +# инстанс поднимался лениво на поставщика и не закрывался никогда, так что пять +# поставщиков давали пять вечных браузеров. +# +# Откуда 4. Замер прода 07.09.2026 (tradein-browser, mem_limit 2560 МиБ, +# memswap 3g): 4 живых инстанса = 1.70 ГиБ anon (~425 МиБ на инстанс, парент- +# процессы 1.35 ГиБ), memory.current 2.01 ГиБ = 80% лимита. Шестой в лимит не +# влезает, пятый живёт впритык — и это видно в cgroup за те же 25 ч: +# memory.events max=40781, oom_kill=111, sock_throttled=22177. 4 — потолок, при +# котором остаётся запас на саму страницу. +# ⚠ Поднимаешь BROWSER_MAX_INSTANCES — поднимай mem_limit в docker-compose.prod.yml +# (грубо ~0.5 ГиБ на инстанс сверх текущих 2560m). +BROWSER_MAX_INSTANCES: int = max(1, int(os.environ.get("BROWSER_MAX_INSTANCES", "4"))) # Глобальной константы recycle больше нет (#3205): порог стал поставщик-зависимым и # живёт в _RECYCLE_PAGES_BY_PROVIDER. Env BROWSER_RECYCLE_PAGES по-прежнему читается — # внутри _resolve_recycle_pages, как один из уровней приоритета. Константу убрали @@ -838,6 +855,36 @@ _last_response_status: dict[str, int | None] = {} # если запрошенный proxy совпадает с текущим. None = запущен без прокси (прямое). _launched_proxy: dict[str, str | None] = {} + +class _Instance(NamedTuple): + """Всё, что принадлежит ОДНОМУ инстансу camoufox, снятое с per-provider словарей. + + Существует, чтобы инстанс можно было отложить в пул целиком: браузер без своего + context'а/якорной вкладки/пробега — это уже другой инстанс. + """ + + cm: object | None + browser: object | None + context: object | None + anchor_page: object | None + anchor_origin: str | None + pages: int + + +# (provider, proxy_url) → ТЁПЛЫЙ, но не активный инстанс (#3412). Прокси у camoufox +# задаётся НА LAUNCH (geoip=True строит фингерпринт по IP аренды), сменить его у +# живого браузера нельзя — можно только держать второй инстанс. До #3412 второго не +# держали: приход чужой аренды закрывал живой браузер, и вернувшаяся через запрос +# прежняя аренда платила холодный старт заново (прод 06-07.09.2026: 49 таких +# закрытий за 25 ч, пачками до 24 в час — в такие часы холодный старт стоял перед +# КАЖДЫМ fetch'ем). +_parked: dict[tuple[str, str | None], _Instance] = {} +# (provider, proxy_url) → порядковый номер последнего использования. Счётчик, а не +# часы: LRU нужен строгий порядок, а два вызова в одну миллисекунду часами +# неразличимы. +_instance_use_seq: dict[tuple[str, str | None], int] = {} +_use_counter: int = 0 + # Guard на ленивое создание per-provider локов: setdefault на обычном dict из # разных корутин гонок не даёт (нет await между read-modify-write), но держим # явный guard на случай будущей сложной инициализации. Создаётся в _on_startup. @@ -1052,6 +1099,194 @@ async def _close_browser(provider: str) -> None: _launched_proxy.pop(provider, None) +# ── пул тёплых инстансов (#3412) ─────────────────────────────────────────────── +# +# Инвариант параллельности: ВСЯ работа поставщика идёт под _locks[provider], а +# снятие/установка инстанса делается СИНХРОННО (подряд идущие dict-операции без +# единого await). Поэтому чужая корутина никогда не видит наполовину снятое +# состояние и не может получить ссылку на браузер, который вот-вот закроют: +# закрывает его только тот, кто первым сделал pop. Новых локов не заводим. + + +def _touch_instance(provider: str, proxy_url: str | None) -> None: + """Отмечает инстанс как только что использованный (для LRU-выбора жертвы).""" + global _use_counter + _use_counter += 1 + _instance_use_seq[(provider, proxy_url)] = _use_counter + + +def _live_instances() -> int: + """Сколько camoufox сейчас живо: активные + припаркованные.""" + return len(_browsers) + len(_parked) + + +def _detach_active(provider: str) -> _Instance | None: + """Синхронно снимает активный инстанс поставщика со ВСЕХ per-provider словарей. + + Возвращает снятое (None — инстанса не было). Ни одного await внутри: снятие + обязано быть атомарным относительно других корутин, иначе параллельный запрос + успеет схватить ссылку на браузер, который уже решено закрыть. + + _last_goto_at НЕ трогает: пейсинг — это ритм обращений К ПЛОЩАДКЕ, он переживает + смену инстанса. Вызывающий, который закрывает инстанс насовсем, чистит его сам. + """ + browser = _browsers.pop(provider, None) + cm = _browser_cms.pop(provider, None) + _launched_proxy.pop(provider, None) + context = _contexts.pop(provider, None) + anchor_page = _anchor_pages.pop(provider, None) + anchor_origin = _anchor_origins.pop(provider, None) + pages = _page_counters.pop(provider, 0) + if browser is None and cm is None: + return None + return _Instance(cm, browser, context, anchor_page, anchor_origin, pages) + + +async def _close_instance(provider: str, inst: _Instance) -> None: + """Закрывает СНЯТЫЙ инстанс (уже недостижимый ни для кого): context → браузер. + + Best-effort, как _close_browser: ошибка teardown'а не должна ронять вызывающего. + Якорная вкладка умирает вместе с context'ом — отдельно её не закрываем. + """ + if inst.context is not None: + try: + await inst.context.close() # type: ignore[attr-defined] + except Exception as exc: + logger.warning( + "tradein-browser[%s]: ошибка при закрытии context снятого инстанса: %s", + provider, + type(exc).__name__, + ) + if inst.cm is not None: + try: + await inst.cm.__aexit__(None, None, None) # type: ignore[attr-defined] + except Exception as exc: + logger.warning( + "tradein-browser[%s]: ошибка при закрытии снятого инстанса: %s", + provider, + type(exc).__name__, + ) + + +async def _park_active(provider: str) -> None: + """Убирает активный инстанс поставщика в пул под ключом ЕГО аренды. + + No-op, если активного инстанса нет. Ключ берём ДО снятия: _detach_active + вычищает _launched_proxy. + """ + proxy = _launched_proxy.get(provider) + inst = _detach_active(provider) + if inst is None: + return + key = (provider, proxy) + displaced = _parked.pop(key, None) # дубля быть не должно; если есть — не течём + _parked[key] = inst + _touch_instance(provider, proxy) + if displaced is not None: + await _close_instance(provider, displaced) + + +def _adopt_parked(provider: str, proxy_url: str | None) -> bool: + """Делает припаркованный инстанс этой аренды активным. False — такого нет. + + Пробег страниц едет ВМЕСТЕ с инстансом: recycle-порог считает страницы одного + браузера, а не сумму по поставщику. + """ + inst = _parked.pop((provider, proxy_url), None) + if inst is None: + return False + _browsers[provider] = inst.browser + if inst.cm is not None: + _browser_cms[provider] = inst.cm + _launched_proxy[provider] = proxy_url + _page_counters[provider] = inst.pages + if inst.context is not None: + _contexts[provider] = inst.context + if inst.anchor_page is not None: + _anchor_pages[provider] = inst.anchor_page + if inst.anchor_origin is not None: + _anchor_origins[provider] = inst.anchor_origin + _touch_instance(provider, proxy_url) + return True + + +def _pick_evictable(exclude: str) -> tuple[str, str | None] | None: + """LRU-жертва среди тех инстансов, которые ТОЧНО никто не использует. + + Кандидаты: все припаркованные (по построению не заняты — их держит только + словарь) плюс активные инстансы ЧУЖИХ поставщиков со СВОБОДНЫМ _locks: вся + работа поставщика идёт под его локом, значит свободный лок = никто не внутри. + Свой (exclude) поставщик не трогается — его инстанс нужен прямо сейчас. + None — свободных нет. + """ + candidates: list[tuple[str, str | None]] = list(_parked) + for provider in list(_browsers): + if provider == exclude: + continue + lock = _locks.get(provider) + if lock is not None and lock.locked(): + continue # поставщик прямо сейчас работает на этом инстансе + candidates.append((provider, _launched_proxy.get(provider))) + if not candidates: + return None + return min(candidates, key=lambda key: _instance_use_seq.get(key, 0)) + + +async def _evict_instance(key: tuple[str, str | None]) -> bool: + """Закрывает выбранный инстанс: сначала синхронный pop, потом await close. + + Возвращает True, если слот реально освободился. False (жертва уже уехала) — + сигнал вызывающему остановиться, а не выбирать снова: цикл, который не + уменьшает счётчик живых, крутился бы вечно под локом поставщика. + """ + provider, proxy = key + _instance_use_seq.pop(key, None) + inst = _parked.pop(key, None) + if inst is not None: + logger.info( + "tradein-browser[%s]: припаркованный инстанс закрыт по потолку (%d)", + provider, + BROWSER_MAX_INSTANCES, + ) + else: + if _launched_proxy.get(provider) != proxy: + return False # состояние уехало между выбором и снятием + inst = _detach_active(provider) + _last_goto_at.pop(provider, None) # инстанс уходит насовсем + if inst is None: + return True # запись из _browsers всё равно снята — слот свободен + logger.info( + "tradein-browser[%s]: инстанс закрыт по потолку (%d) — простаивал дольше всех", + provider, + BROWSER_MAX_INSTANCES, + ) + await _close_instance(provider, inst) + return True + + +async def _trim_instances(exclude: str) -> None: + """Освобождает место под НОВЫЙ инстанс: закрывает LRU, пока живых >= потолка. + + ponytail: потолок best-effort — если все прочие инстансы заняты, поднимаемся + сверх него и говорим об этом в лог, а не отказываем клиенту и не убиваем + чужую страницу на середине навигации. Апгрейд — очередь ожидания на слот, + если предупреждение начнёт появляться регулярно. + """ + while _live_instances() >= BROWSER_MAX_INSTANCES: + key = _pick_evictable(exclude) + if key is None: + logger.warning( + "tradein-browser[%s]: живых инстансов %d при потолке %d, свободных для " + "закрытия нет (все заняты) — поднимаем сверх потолка", + exclude, + _live_instances(), + BROWSER_MAX_INSTANCES, + ) + return + if not await _evict_instance(key): + return + + async def _relaunch_browser(provider: str, proxy_override: str | None = None) -> None: """Закрывает и заново запускает per-provider браузер (recycle / crash-recovery). @@ -1098,12 +1333,15 @@ async def _ensure_browser(provider: str, proxy_override: str | None = None) -> b """Гарантирует поднятый инстанс поставщика с нужным прокси; lazy launch / relaunch. proxy_override (#2164 P4): желаемый динамический прокси из пула (тело /fetch). None → - желаемый прокси = env (_provider_proxy). Политика «relaunch ТОЛЬКО при реальной смене»: - - инстанса нет → launch с желаемым прокси; - - инстанс есть, но _launched_proxy[provider] != желаемого → close + relaunch; - - инстанс есть и прокси совпадает → no-op (НЕ релончим — camoufox дорого поднимать). + желаемый прокси = env (_provider_proxy). Инстанс привязан к аренде НА LAUNCH, поэтому + политика такая (#3412 — пул вместо teardown'а): + - инстанс есть и аренда совпадает → no-op; + - аренда сменилась → активный инстанс ПАРКУЕТСЯ (не закрывается) под своей + арендой, а желаемая аренда берётся из пула тёплой; нет такой в пуле → launch + нового инстанса, предварительно освободив место под потолком; + - инстанса нет → пул, иначе launch. При выключенном флаге на scraper-стороне proxy_override всегда None → желаемый=env= - текущий → релонча нет, поведение идентично прежнему (golden-parity). + текущий → ни парковки, ни launch'а, поведение идентично прежнему (golden-parity). Caller держит _locks[provider] (весь доступ к инстансу сериализован), поэтому здесь своей сериализации нет — двух одновременных launch'ей на один provider быть не может. @@ -1117,19 +1355,40 @@ 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: - return True - # Реальная смена прокси → пересобрать инстанс с новым прокси. + had_instance = _browsers.get(provider) is not None + if had_instance and _launched_proxy.get(provider) == desired_proxy: + _touch_instance(provider, desired_proxy) + return True + if had_instance: + # Аренда сменилась. Живой браузер НЕ убиваем — откладываем в пул под ЕГО + # арендой: вернётся эта аренда (в проде они чередуются между параллельными + # сессиями) — возьмём его тёплым, без холодного старта. + await _park_active(provider) + + if _adopt_parked(provider, desired_proxy): logger.info( - "tradein-browser[%s]: прокси изменился (override=%s) — relaunch перед fetch", + "tradein-browser[%s]: аренда сменилась (override=%s) — тёплый инстанс из " + "пула (живых %d/%d)", provider, proxy_override is not None, + _live_instances(), + BROWSER_MAX_INSTANCES, ) - await _close_browser(provider) + return True + if had_instance: + logger.info( + "tradein-browser[%s]: аренда сменилась (override=%s) — в пуле такой нет, " + "новый инстанс (живых %d/%d)", + provider, + proxy_override is not None, + _live_instances(), + BROWSER_MAX_INSTANCES, + ) + await _trim_instances(provider) await _try_launch_browser(provider, proxy_override=proxy_override) if _browsers.get(provider) is not None: + _touch_instance(provider, desired_proxy) return True _start_retry_task(provider) return False @@ -1179,10 +1438,12 @@ async def _on_startup(app: web.Application) -> None: # НЕ поднимаются здесь. Lazy-launch: инстанс конкретного поставщика стартует на # первом /fetch (или /login) к нему через _ensure_browser под _locks[provider]. # - # Eager-launch всех 4 на старте давал бы 4 camoufox-инстанса сразу → риск OOM: - # до 4 camoufox-инстансов (avito/cian/yandex/generic), lazy; ~0.5-1ГБ RSS - # каждый, mem_limit контейнера 5g. Resilience (фоновый retry при недоступном - # прокси) запускается при первом обращении к поставщику, не для всех сразу. + # Eager-launch всех поставщиков на старте поднял бы все camoufox сразу → OOM: + # ~425 МиБ на инстанс (прод-замер 07.09.2026), mem_limit контейнера 2560m. + # Сколько их держим одновременно — BROWSER_MAX_INSTANCES (#3412); раньше + # потолка не было и пять поставщиков давали пять вечных браузеров. Resilience + # (фоновый retry при недоступном прокси) запускается при первом обращении к + # поставщику, не для всех сразу. global _locks_guard _locks_guard = asyncio.Lock() logger.info( @@ -1193,6 +1454,11 @@ 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: пул инстансов — потолок живых camoufox %d (активные + " + "припаркованные по аренде)", + BROWSER_MAX_INSTANCES, + ) _warn_retired_env() @@ -1208,6 +1474,16 @@ _RETIRED_ENV: dict[str, str] = { "BROWSER_BLOCK_RESOURCES": ( "BROWSER_BLOCK_RESOURCE_TYPES (CSV типов; пустая строка = не блокировать)" ), + # Задана в docker-compose.prod.yml, но кодом НЕ читается ни здесь, ни в backend + # (проверено grep'ом 07.09.2026, #3412). Опаснее прочих мёртвых ручек: ею + # ОБОСНОВАН потолок памяти контейнера («2.5g — потолок для 1-за-раз») и + # обоснование в app/core/config.py («браузер сериализован BROWSER_CONCURRENCY=1»). + # На деле параллелизм задаёт _locks[provider]: ≤1 страница НА ПОСТАВЩИКА, но + # поставщиков пять — до пяти страниц и пяти браузеров одновременно. + "BROWSER_CONCURRENCY": ( + "_locks[provider] (≤1 операция на поставщика) + BROWSER_MAX_INSTANCES " + "(потолок живых инстансов на весь сайдкар)" + ), } @@ -1242,9 +1518,13 @@ async def _on_cleanup(app: web.Application) -> None: ) _retry_tasks.clear() - # Закрываем все поднятые инстансы. + # Закрываем все поднятые инстансы — и активные, и припаркованные в пуле (#3412): + # припаркованный camoufox — такой же живой процесс, забыть его на shutdown значит + # оставить Firefox сиротой до SIGKILL от docker. for provider in list(_browsers.keys()): await _close_browser(provider) + for key in list(_parked): + await _evict_instance(key) # ── handlers ─────────────────────────────────────────────────────────────────── diff --git a/tradein-mvp/browser/test_server_instance_pool.py b/tradein-mvp/browser/test_server_instance_pool.py new file mode 100644 index 00000000..f4db8d8d --- /dev/null +++ b/tradein-mvp/browser/test_server_instance_pool.py @@ -0,0 +1,281 @@ +"""test_server_instance_pool.py — пул тёплых инстансов camoufox (#3412). + +До правки инстанс был ОДИН на поставщика: приходила другая аренда прокси — +`_ensure_browser` закрывал живой браузер и поднимал холодный, а вернувшаяся +через запрос прежняя аренда платила холодный старт заново. Инстансы при этом +никто не считал: пять поставщиков = пять вечно живых camoufox в контейнере с +mem_limit 2560 МиБ (замер прода 07.09.2026: 4 живых = 1.70 ГиБ anon, +memory.events max=40781, oom_kill=111 за 25 ч). + +Проверяет: + 1. два fetch'а с ОДНОЙ арендой — один launch (счётчик запусков == 1); + 2. смена аренды переключает инстанс (активный браузер другой, _launched_proxy + обновлён), а ВЕРНУВШАЯСЯ аренда берёт ТОТ ЖЕ тёплый инстанс, без launch'а; + 3. env-аренда (proxy_override=None) паркуется и восстанавливается так же; + 4. потолок живых инстансов соблюдается: лишний (LRU) закрывается. + +camoufox НЕ запускается: _launch_browser подменён заглушкой, которая заполняет +_browsers/_browser_cms/_launched_proxy ровно как настоящий launch. + +Запуск (из tradein-mvp/browser/):: + + python -m pytest test_server_instance_pool.py -q +""" + +from __future__ import annotations + +import asyncio +import importlib.util +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) + +_P1 = "http://u:p@node1:8080" +_P2 = "http://u:p@node2:8080" +_P3 = "http://u:p@node3:8080" + + +class _FakeBrowser: + """Живой браузер: closed растёт, когда инстанс реально закрыли.""" + + def __init__(self, provider: str, proxy: str | None) -> None: + self.provider = provider + self.proxy = proxy + self.closed = 0 + + +class _FakeCM: + """AsyncCamoufox CM: __aexit__ помечает браузер закрытым (как настоящий teardown).""" + + def __init__(self, browser: _FakeBrowser) -> None: + self._browser = browser + + async def __aexit__(self, *_exc: object) -> None: + self._browser.closed += 1 + + +@pytest.fixture(autouse=True) +def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(server, "_browsers", {}) + monkeypatch.setattr(server, "_browser_cms", {}) + monkeypatch.setattr(server, "_contexts", {}) + monkeypatch.setattr(server, "_anchor_pages", {}) + monkeypatch.setattr(server, "_anchor_origins", {}) + monkeypatch.setattr(server, "_page_counters", {}) + monkeypatch.setattr(server, "_locks", {}) + monkeypatch.setattr(server, "_retry_tasks", {}) + monkeypatch.setattr(server, "_last_goto_at", {}) + monkeypatch.setattr(server, "_launched_proxy", {}) + monkeypatch.setattr(server, "_locks_guard", asyncio.Lock()) + # Состояние пула: raising=False — на версии сервера без пула этих имён нет, + # и тест обязан падать ПО ЗНАЧЕНИЮ (лишние launch'и / лишние живые инстансы), + # а не AttributeError'ом на подготовке. + monkeypatch.setattr(server, "_parked", {}, raising=False) + monkeypatch.setattr(server, "_instance_use_seq", {}, raising=False) + + +def _install_fake_launch(monkeypatch: pytest.MonkeyPatch) -> list[_FakeBrowser]: + """Мок _launch_browser: заполняет состояние как настоящий, копит созданные браузеры.""" + created: list[_FakeBrowser] = [] + + async def _fake_launch(provider: str, proxy_override: str | None = None) -> None: + proxy = proxy_override if proxy_override is not None else server._provider_proxy(provider) + browser = _FakeBrowser(provider, proxy) + created.append(browser) + server._browsers[provider] = browser + server._browser_cms[provider] = _FakeCM(browser) + server._page_counters[provider] = 0 + server._launched_proxy[provider] = proxy + + monkeypatch.setattr(server, "_launch_browser", _fake_launch) + return created + + +def _alive(created: list[_FakeBrowser]) -> list[_FakeBrowser]: + return [b for b in created if b.closed == 0] + + +# ── 1. одна аренда — один запуск ──────────────────────────────────────────────── + + +def test_same_lease_two_fetches_launch_once(monkeypatch: pytest.MonkeyPatch) -> None: + """Два подряд fetch'а с ОДНОЙ арендой не перезапускают браузер.""" + created = _install_fake_launch(monkeypatch) + + async def _run() -> None: + await server._ensure_browser("cian", proxy_override=_P1) + await server._ensure_browser("cian", proxy_override=_P1) + + asyncio.run(_run()) + + assert len(created) == 1 + assert created[0].closed == 0 + + +# ── 2. смена аренды: переключение инстанса, возврат аренды — без launch'а ──────── + + +def test_lease_switch_swaps_instance(monkeypatch: pytest.MonkeyPatch) -> None: + """Другая аренда — другой инстанс: активный браузер сменился, прокси обновлён.""" + created = _install_fake_launch(monkeypatch) + + async def _run() -> None: + await server._ensure_browser("cian", proxy_override=_P1) + await server._ensure_browser("cian", proxy_override=_P2) + + asyncio.run(_run()) + + assert len(created) == 2 + assert server._launched_proxy["cian"] == _P2 + assert server._browsers["cian"] is created[1] + assert server._browsers["cian"] is not created[0] + + +def test_returning_lease_reuses_warm_instance(monkeypatch: pytest.MonkeyPatch) -> None: + """Аренда вернулась — берём ТОТ ЖЕ тёплый инстанс, третьего launch'а нет. + + Красное до правки: каждый вход с чужой арендой убивал живой браузер, поэтому + p1 → p2 → p1 стоило трёх холодных стартов вместо двух. + """ + created = _install_fake_launch(monkeypatch) + + async def _run() -> None: + await server._ensure_browser("cian", proxy_override=_P1) + await server._ensure_browser("cian", proxy_override=_P2) + await server._ensure_browser("cian", proxy_override=_P1) + + asyncio.run(_run()) + + assert len(created) == 2, "вернувшаяся аренда обязана взять припаркованный инстанс" + assert server._browsers["cian"] is created[0] + assert server._launched_proxy["cian"] == _P1 + assert created[0].closed == 0 + + +def test_env_lease_none_override_is_parked_and_restored( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """proxy_override=None (env-аренда) паркуется и восстанавливается как обычная.""" + monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080") + created = _install_fake_launch(monkeypatch) + + async def _run() -> None: + await server._ensure_browser("cian") # env-аренда + await server._ensure_browser("cian", proxy_override=_P1) + await server._ensure_browser("cian") # вернулись на env + + asyncio.run(_run()) + + assert len(created) == 2 + assert server._browsers["cian"] is created[0] + assert server._launched_proxy["cian"] == "http://env:8080" + + +def test_page_counter_travels_with_instance(monkeypatch: pytest.MonkeyPatch) -> None: + """Счётчик страниц едет С инстансом: у вернувшегося браузера свой пробег.""" + created = _install_fake_launch(monkeypatch) + + async def _run() -> None: + await server._ensure_browser("cian", proxy_override=_P1) + server._page_counters["cian"] = 7 + await server._ensure_browser("cian", proxy_override=_P2) + server._page_counters["cian"] = 2 + await server._ensure_browser("cian", proxy_override=_P1) + + asyncio.run(_run()) + + assert len(created) == 2 + assert server._page_counters["cian"] == 7 + + +# ── 3. потолок живых инстансов ───────────────────────────────────────────────── + + +def test_live_instances_capped_lru_closed(monkeypatch: pytest.MonkeyPatch) -> None: + """Потолок живых camoufox соблюдается: лишний (давно не использованный) закрыт. + + Красное до правки: инстансы копились по одному на поставщика и не закрывались + никогда — пять поставщиков давали пять живых браузеров в контейнере, чьего + mem_limit хватает на четыре. + """ + monkeypatch.setattr(server, "BROWSER_MAX_INSTANCES", 3, raising=False) + created = _install_fake_launch(monkeypatch) + + async def _run() -> None: + for provider in ("avito", "cian", "yandex", "generic", "domclick"): + await server._ensure_browser(provider, proxy_override=_P1) + + asyncio.run(_run()) + + assert len(created) == 5 + alive = _alive(created) + assert len(alive) <= 3, f"живых инстансов {len(alive)} при потолке 3" + # Закрыты именно самые старые по использованию, свежие живы. + assert [b.provider for b in alive] == ["yandex", "generic", "domclick"] + + +def test_busy_provider_is_not_evicted(monkeypatch: pytest.MonkeyPatch) -> None: + """Инстанс поставщика с ЗАНЯТЫМ локом не закрывают — он держит страницу in-flight. + + Занятый лок — единственный признак «поставщик прямо сейчас внутри»: вся его + работа идёт под этим локом. Спутать его с простаивающим значит убить чужую + навигацию на середине. + """ + monkeypatch.setattr(server, "BROWSER_MAX_INSTANCES", 2, raising=False) + created = _install_fake_launch(monkeypatch) + + async def _run() -> None: + await server._ensure_browser("avito", proxy_override=_P1) + await server._ensure_browser("cian", proxy_override=_P1) + # avito «работает»: его лок захвачен, значит трогать инстанс нельзя, хотя + # он и самый давний по использованию. + busy = await server._lock_for("avito") + await busy.acquire() + try: + await server._ensure_browser("yandex", proxy_override=_P1) + finally: + busy.release() + + asyncio.run(_run()) + + avito, cian, _yandex = created + assert avito.closed == 0, "занятый поставщик закрыт — убили бы страницу in-flight" + assert cian.closed == 1, "жертвой стал следующий по давности, свободный" + + +def test_cleanup_closes_parked_instances(monkeypatch: pytest.MonkeyPatch) -> None: + """Shutdown закрывает и припаркованные инстансы — иначе Firefox остаётся сиротой.""" + created = _install_fake_launch(monkeypatch) + + async def _run() -> None: + await server._ensure_browser("cian", proxy_override=_P1) + await server._ensure_browser("cian", proxy_override=_P2) # первый уходит в пул + await server._on_cleanup(None) # type: ignore[arg-type] + + asyncio.run(_run()) + + assert [b.closed for b in created] == [1, 1] + + +def test_cap_counts_parked_instances(monkeypatch: pytest.MonkeyPatch) -> None: + """Припаркованные (тёплые, но не активные) инстансы тоже занимают место под потолком.""" + monkeypatch.setattr(server, "BROWSER_MAX_INSTANCES", 2, raising=False) + created = _install_fake_launch(monkeypatch) + + async def _run() -> None: + await server._ensure_browser("cian", proxy_override=_P1) + await server._ensure_browser("cian", proxy_override=_P2) + await server._ensure_browser("cian", proxy_override=_P3) + + asyncio.run(_run()) + + assert len(created) == 3 + alive = _alive(created) + assert len(alive) <= 2, f"живых инстансов {len(alive)} при потолке 2" + assert server._browsers["cian"] is created[2]