"""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]