Skip to content

Commit 3a8ca10

Browse files
test(docker): regression tests for the permanent-browser restart
1 parent 58b46c2 commit 3a8ca10

1 file changed

Lines changed: 162 additions & 0 deletions

File tree

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Tests for crawler_pool.restart_permanent().
2+
3+
These exercise the real `deploy/docker/crawler_pool.py` module. `crawl4ai` and
4+
`utils` are stubbed before the import so the pool's lifecycle can be tested
5+
without Playwright, a browser, or a config file — no Docker and no running
6+
server needed.
7+
8+
Regression coverage for the permanent-browser restart, which used to call
9+
init_permanent() while holding the pool LOCK. asyncio.Lock is not reentrant,
10+
so that deadlocked the pool for the life of the process: every later
11+
get_crawler() blocked forever while /health kept answering OK.
12+
"""
13+
14+
import asyncio
15+
import importlib
16+
import sys
17+
import types
18+
from pathlib import Path
19+
20+
import pytest
21+
22+
DOCKER_DIR = Path(__file__).resolve().parents[2] / "deploy" / "docker"
23+
24+
25+
# ---------------------------------------------------------------------------
26+
# Stubs for the two modules crawler_pool imports at module scope
27+
# ---------------------------------------------------------------------------
28+
29+
30+
class FakeBrowserConfig:
31+
"""Minimal stand-in: crawler_pool only needs to_dict() for the signature."""
32+
33+
def __init__(self, **kwargs):
34+
self.kwargs = kwargs
35+
36+
def to_dict(self):
37+
return dict(self.kwargs)
38+
39+
40+
class FakeCrawler:
41+
"""Records start/close so a test can assert what happened to a browser."""
42+
43+
instances = []
44+
45+
def __init__(self, config=None, thread_safe=False):
46+
self.config = config
47+
self.started = False
48+
self.closed = False
49+
self.close_gate = None # set to an asyncio.Event to hang close()
50+
FakeCrawler.instances.append(self)
51+
52+
async def start(self):
53+
self.started = True
54+
55+
async def close(self):
56+
if self.close_gate is not None:
57+
await self.close_gate.wait()
58+
self.closed = True
59+
60+
61+
@pytest.fixture
62+
def pool():
63+
"""Import crawler_pool against the stubs, fresh for every test."""
64+
fake_crawl4ai = types.ModuleType("crawl4ai")
65+
fake_crawl4ai.AsyncWebCrawler = FakeCrawler
66+
fake_crawl4ai.BrowserConfig = FakeBrowserConfig
67+
68+
fake_utils = types.ModuleType("utils")
69+
fake_utils.load_config = lambda: {
70+
"crawler": {"memory_threshold_percent": 95.0, "pool": {"idle_ttl_sec": 300}}
71+
}
72+
fake_utils.get_container_memory_percent = lambda: 10.0
73+
74+
saved = {name: sys.modules.get(name) for name in ("crawl4ai", "utils", "crawler_pool")}
75+
sys.modules["crawl4ai"] = fake_crawl4ai
76+
sys.modules["utils"] = fake_utils
77+
sys.modules.pop("crawler_pool", None)
78+
sys.path.insert(0, str(DOCKER_DIR))
79+
80+
FakeCrawler.instances = []
81+
try:
82+
yield importlib.import_module("crawler_pool")
83+
finally:
84+
sys.path.remove(str(DOCKER_DIR))
85+
for name, module in saved.items():
86+
if module is None:
87+
sys.modules.pop(name, None)
88+
else:
89+
sys.modules[name] = module
90+
91+
92+
# ---------------------------------------------------------------------------
93+
# Tests
94+
# ---------------------------------------------------------------------------
95+
96+
97+
@pytest.mark.asyncio
98+
async def test_restart_permanent_replaces_the_browser(pool):
99+
cfg = FakeBrowserConfig(headless=True)
100+
await pool.init_permanent(cfg)
101+
first = pool.PERMANENT
102+
sig_before = pool.DEFAULT_CONFIG_SIG
103+
104+
await asyncio.wait_for(pool.restart_permanent(cfg), timeout=5)
105+
106+
assert first.closed, "the old permanent browser should be closed"
107+
assert pool.PERMANENT is not first, "a new browser should have replaced it"
108+
assert pool.PERMANENT.started, "the replacement should be started"
109+
assert pool.DEFAULT_CONFIG_SIG == sig_before, "same config, same signature"
110+
111+
112+
@pytest.mark.asyncio
113+
async def test_restart_permanent_leaves_the_pool_usable(pool):
114+
"""The regression: after a restart the pool LOCK must still be free."""
115+
cfg = FakeBrowserConfig(headless=True)
116+
await pool.init_permanent(cfg)
117+
118+
await asyncio.wait_for(pool.restart_permanent(cfg), timeout=5)
119+
120+
crawler = await asyncio.wait_for(pool.get_crawler(cfg), timeout=5)
121+
assert crawler is pool.PERMANENT
122+
123+
124+
@pytest.mark.asyncio
125+
async def test_a_hanging_close_does_not_block_the_pool(pool):
126+
"""A browser that will not close must not take the whole server with it.
127+
128+
The restart itself waits on the close, but it holds no lock while it does,
129+
so unrelated requests keep being served.
130+
"""
131+
cfg = FakeBrowserConfig(headless=True)
132+
await pool.init_permanent(cfg)
133+
pool.PERMANENT.close_gate = asyncio.Event() # close() will never return
134+
135+
restart = asyncio.create_task(pool.restart_permanent(cfg))
136+
await asyncio.sleep(0) # let it reach the close
137+
138+
other = FakeBrowserConfig(headless=True, text_mode=True)
139+
crawler = await asyncio.wait_for(pool.get_crawler(other), timeout=5)
140+
assert crawler.started
141+
142+
restart.cancel()
143+
await asyncio.gather(restart, return_exceptions=True)
144+
145+
146+
@pytest.mark.asyncio
147+
async def test_init_permanent_under_the_lock_deadlocks(pool):
148+
"""Documents the original bug: the route did exactly this.
149+
150+
Kept as a guard on the assumption the fix rests on — that LOCK is a plain,
151+
non-reentrant asyncio.Lock. If this ever stops timing out, restart_permanent
152+
can be simplified; until then, nothing may call init_permanent() while
153+
holding it.
154+
"""
155+
cfg = FakeBrowserConfig(headless=True)
156+
157+
async def restart_the_old_way():
158+
async with pool.LOCK:
159+
await pool.init_permanent(cfg)
160+
161+
with pytest.raises(asyncio.TimeoutError):
162+
await asyncio.wait_for(restart_the_old_way(), timeout=1)

0 commit comments

Comments
 (0)