|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +import asyncio |
3 | 4 | from datetime import timedelta |
4 | 5 | from typing import TYPE_CHECKING |
5 | 6 | from unittest.mock import AsyncMock |
6 | 7 |
|
7 | 8 | import pytest |
8 | 9 |
|
9 | | -from crawlee.browsers import BrowserPool, PlaywrightBrowserPlugin |
| 10 | +from crawlee.browsers import BrowserPool, PlaywrightBrowserController, PlaywrightBrowserPlugin |
10 | 11 | from crawlee.browsers._browser_controller import BrowserController |
11 | 12 | from crawlee.browsers._types import CrawleePage |
12 | 13 |
|
13 | 14 | if TYPE_CHECKING: |
14 | 15 | from collections.abc import Mapping |
15 | 16 | from typing import Any |
16 | 17 |
|
| 18 | + from playwright.async_api import BrowserContext, Page |
17 | 19 | from yarl import URL |
18 | 20 |
|
19 | 21 | from crawlee.browsers._browser_plugin import BrowserPlugin |
@@ -159,6 +161,51 @@ async def test_resource_management(server_url: URL) -> None: |
159 | 161 | assert page.page.is_closed() |
160 | 162 |
|
161 | 163 |
|
| 164 | +async def test_reaper_does_not_close_browser_with_page_opening_in_flight(monkeypatch: pytest.MonkeyPatch) -> None: |
| 165 | + """The inactive-browser reaper leaves a browser alone while its `new_page` call is still in flight.""" |
| 166 | + opening_in_flight = asyncio.Event() |
| 167 | + resume_opening = asyncio.Event() |
| 168 | + original_create_context = PlaywrightBrowserController._create_browser_context |
| 169 | + |
| 170 | + async def create_context_with_gated_first_page( |
| 171 | + self: PlaywrightBrowserController, *args: Any, **kwargs: Any |
| 172 | + ) -> BrowserContext: |
| 173 | + context = await original_create_context(self, *args, **kwargs) |
| 174 | + original_new_page = context.new_page |
| 175 | + |
| 176 | + async def gated_new_page(*new_page_args: Any, **new_page_kwargs: Any) -> Page: |
| 177 | + opening_in_flight.set() |
| 178 | + await resume_opening.wait() |
| 179 | + return await original_new_page(*new_page_args, **new_page_kwargs) |
| 180 | + |
| 181 | + monkeypatch.setattr(context, 'new_page', gated_new_page) |
| 182 | + return context |
| 183 | + |
| 184 | + monkeypatch.setattr(PlaywrightBrowserController, '_create_browser_context', create_context_with_gated_first_page) |
| 185 | + |
| 186 | + # Long reaper intervals so that only the manual calls below drive the reaping; a zero inactivity |
| 187 | + # threshold makes the freshly launched browser eligible for it right away. |
| 188 | + async with BrowserPool( |
| 189 | + browser_inactive_threshold=timedelta(seconds=0), |
| 190 | + identify_inactive_browsers_interval=timedelta(hours=1), |
| 191 | + close_inactive_browsers_interval=timedelta(hours=1), |
| 192 | + ) as browser_pool: |
| 193 | + new_page_task = asyncio.create_task(browser_pool.new_page()) |
| 194 | + await asyncio.wait_for(opening_in_flight.wait(), timeout=60) |
| 195 | + |
| 196 | + # Run one reaper cycle, exactly as the recurring tasks would, while the page opening is pending. |
| 197 | + browser_pool._identify_inactive_browsers() |
| 198 | + await browser_pool._close_inactive_browsers() |
| 199 | + |
| 200 | + resume_opening.set() |
| 201 | + page = await new_page_task |
| 202 | + |
| 203 | + assert not page.page.is_closed() |
| 204 | + assert browser_pool.total_pages_count == 1 |
| 205 | + |
| 206 | + await page.page.close() |
| 207 | + |
| 208 | + |
162 | 209 | async def test_methods_raise_error_when_not_active() -> None: |
163 | 210 | plugin = PlaywrightBrowserPlugin() |
164 | 211 | browser_pool = BrowserPool([plugin]) |
|
0 commit comments