Coverage for src/lilbee/crawler/bootstrap.py: 100%

161 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""Playwright Chromium detection and install.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import contextlib 

7import os 

8import re 

9import sys 

10from pathlib import Path 

11 

12from filelock import FileLock 

13from filelock import Timeout as FileLockTimeout 

14 

15from lilbee._frozen import is_frozen 

16from lilbee.runtime.progress import ( 

17 DetailedProgressCallback, 

18 EventType, 

19 SetupDoneEvent, 

20 SetupProgressEvent, 

21 SetupStartEvent, 

22) 

23 

24 

25class CrawlerBrowserError(RuntimeError): 

26 """Playwright is installed but its Chromium browser binary is not.""" 

27 

28 

29class CrawlerBackendError(RuntimeError): 

30 """The ``crawler`` extra (crawl4ai) was never installed.""" 

31 

32 

33_CHROMIUM_COMPONENT = "chromium" 

34 

35# A Chromium unpack runs into the minutes on a slow link, so a caller queued 

36# behind one waits well past any normal request timeout before giving up. 

37_BOOTSTRAP_LOCK_TIMEOUT_S = 900.0 

38# Rough size estimate for the Chromium download; Playwright bundles vary 

39# slightly per platform but this gives the UI a decent denominator before 

40# 'Total bytes' parses out of stdout. 

41_CHROMIUM_ESTIMATE_MB = 180 

42_CHROMIUM_SIZE_ESTIMATE_BYTES = _CHROMIUM_ESTIMATE_MB * 1024 * 1024 

43 

44# Unit -> bytes scale for Playwright stdout progress lines. 

45_BYTE_UNIT_SCALE: dict[str, int] = { 

46 "b": 1, 

47 "kb": 1024, 

48 "kib": 1024, 

49 "mb": 1024 * 1024, 

50 "mib": 1024 * 1024, 

51} 

52 

53# Playwright 1.58 prints lines like 

54# ``|■■■■■■■■ | 10% of 162.3 MiB`` during 

55# the chromium download. The percent comes first, then "of <total> <unit>". 

56_PROGRESS_LINE_RE = re.compile( 

57 r"(\d+)\s*%\s*of\s*(\d+(?:\.\d+)?)\s*(MiB|Mb|MB|KiB|KB|B)", 

58 re.IGNORECASE, 

59) 

60 

61 

62def _browsers_cache_path() -> Path: 

63 """Return the root path where Playwright stores browser binaries.""" 

64 override = os.environ.get("PLAYWRIGHT_BROWSERS_PATH") 

65 if override: 

66 return Path(override).expanduser() 

67 if sys.platform == "darwin": 

68 return Path.home() / "Library" / "Caches" / "ms-playwright" 

69 if sys.platform == "win32": 

70 local = os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local")) 

71 return Path(local) / "ms-playwright" 

72 return Path.home() / ".cache" / "ms-playwright" 

73 

74 

75def _read_chromium_revision(browsers_json: Path) -> str | None: 

76 """Pull the ``chromium`` revision out of a Playwright ``browsers.json``.""" 

77 import json 

78 

79 try: 

80 data = json.loads(browsers_json.read_text(encoding="utf-8")) 

81 except (OSError, ValueError): 

82 return None 

83 for browser in data.get("browsers", []): 

84 if browser.get("name") == _CHROMIUM_COMPONENT: 

85 revision = browser.get("revision") 

86 return str(revision) if revision is not None else None 

87 return None 

88 

89 

90def _expected_chromium_revision() -> str | None: 

91 """Revision string Playwright was built against (e.g. ``"1217"``). 

92 

93 ``None`` means "unknown" -- treat as "do install" so a missing 

94 browsers.json never short-circuits the bootstrap path. 

95 """ 

96 try: 

97 import playwright as _pw 

98 except ImportError: 

99 return None 

100 for path in Path(_pw.__file__).parent.rglob("browsers.json"): 

101 return _read_chromium_revision(path) 

102 return None 

103 

104 

105def chromium_installed() -> bool: 

106 """Return True if the Chromium revision Playwright expects is on disk. 

107 

108 Matching by any ``chromium-*`` directory isn't enough: when the 

109 system has chromium-1217 but the bundled Playwright driver expects 

110 chromium-1208, launch fails with ``Executable doesn't exist`` even 

111 though the bootstrap check thought everything was ready. 

112 """ 

113 root = _browsers_cache_path() 

114 if not root.exists(): 

115 return False 

116 expected = _expected_chromium_revision() 

117 if expected is None: 

118 return any(p.is_dir() and p.name.startswith("chromium-") for p in root.iterdir()) 

119 return (root / f"{_CHROMIUM_COMPONENT}-{expected}").is_dir() 

120 

121 

122def _bootstrap_lock_path() -> Path: 

123 """Lock file serializing Chromium installs into the browsers cache.""" 

124 root = _browsers_cache_path() 

125 root.mkdir(parents=True, exist_ok=True) 

126 return root / ".lilbee-bootstrap.lock" 

127 

128 

129def crawler_browsers_path() -> Path: 

130 """Public accessor for the crawler browser cache root. 

131 

132 Used by the HTTP status endpoint to tell plugins where Chromium 

133 lives. The underlying resolver stays private because callers should 

134 not depend on the Playwright-specific directory layout. 

135 """ 

136 return _browsers_cache_path() 

137 

138 

139def _bytes_from_stdout(line: str) -> tuple[int, int] | None: 

140 """Extract (downloaded_bytes, total_bytes) from a Playwright stdout line. 

141 

142 Matches the ``NN% of N.N MiB`` shape Playwright 1.58+ emits for the 

143 Chromium download. Returns None when the line doesn't match. The 

144 percent and total both parse out of the same line so callers never 

145 have to handle a missing total. 

146 """ 

147 match = _PROGRESS_LINE_RE.search(line) 

148 if match is None: 

149 return None 

150 pct = int(match.group(1)) 

151 raw_total = float(match.group(2)) 

152 unit = match.group(3).lower() 

153 scale = _BYTE_UNIT_SCALE.get(unit, 1) 

154 total = int(raw_total * scale) 

155 downloaded = int(total * pct / 100) 

156 return downloaded, total 

157 

158 

159def _emit_setup_start(on_progress: DetailedProgressCallback | None) -> None: 

160 if on_progress is None: 

161 return 

162 on_progress( 

163 EventType.SETUP_START, 

164 SetupStartEvent( 

165 component=_CHROMIUM_COMPONENT, 

166 size_estimate_bytes=_CHROMIUM_SIZE_ESTIMATE_BYTES, 

167 ), 

168 ) 

169 

170 

171def _emit_setup_done( 

172 on_progress: DetailedProgressCallback | None, 

173 *, 

174 success: bool, 

175 error: str | None, 

176) -> None: 

177 if on_progress is None: 

178 return 

179 on_progress( 

180 EventType.SETUP_DONE, 

181 SetupDoneEvent(component=_CHROMIUM_COMPONENT, success=success, error=error), 

182 ) 

183 

184 

185async def _drain_stdout_to_progress( 

186 stream: asyncio.StreamReader, 

187 on_progress: DetailedProgressCallback | None, 

188) -> None: 

189 while True: 

190 line_bytes = await stream.readline() 

191 if not line_bytes: 

192 return 

193 line = line_bytes.decode(errors="replace").rstrip() 

194 parsed = _bytes_from_stdout(line) 

195 if parsed is None or on_progress is None: 

196 continue 

197 downloaded, total = parsed 

198 on_progress( 

199 EventType.SETUP_PROGRESS, 

200 SetupProgressEvent( 

201 component=_CHROMIUM_COMPONENT, 

202 downloaded_bytes=downloaded, 

203 total_bytes=total, 

204 detail=line, 

205 ), 

206 ) 

207 

208 

209async def _drain_stderr(stream: asyncio.StreamReader, tail: list[str]) -> None: 

210 while True: 

211 line_bytes = await stream.readline() 

212 if not line_bytes: 

213 return 

214 tail.append(line_bytes.decode(errors="replace").rstrip()) 

215 

216 

217_PLAYWRIGHT_MISSING_HINT = ( 

218 "Chromium bootstrap requires the playwright Python package, which is " 

219 "bundled with the release binary and the lilbee[crawler] extra. " 

220 "Reinstall with 'pip install lilbee[crawler]' or download a fresh " 

221 "release binary." 

222) 

223 

224 

225def _resolve_playwright_runner() -> tuple[list[str], dict[str, str]]: 

226 """Return ``(argv_prefix, env)`` for invoking ``playwright install chromium``. 

227 

228 Spawns Playwright's bundled Node driver directly so the call works under a 

229 pip install, ``uv tool install``, or a frozen (Nuitka onefile) binary. Falls 

230 back to ``[sys.executable, '-m', 'playwright']`` for unfrozen builds when the 

231 driver lookup fails; re-raises for frozen builds, where ``sys.executable`` 

232 is the lilbee exe and ``-m playwright`` would leak into typer. 

233 """ 

234 try: 

235 from playwright._impl._driver import compute_driver_executable, get_driver_env 

236 except ImportError as exc: 

237 raise CrawlerBrowserError(_PLAYWRIGHT_MISSING_HINT) from exc 

238 try: 

239 driver_exe, driver_cli = compute_driver_executable() 

240 except Exception: 

241 if not is_frozen(): 

242 return [sys.executable, "-m", "playwright"], dict(os.environ) 

243 raise 

244 return [str(driver_exe), str(driver_cli)], dict(get_driver_env()) 

245 

246 

247async def bootstrap_chromium( 

248 on_progress: DetailedProgressCallback | None = None, 

249) -> None: 

250 """Run ``playwright install chromium`` as a subprocess, emitting events. 

251 

252 Short-circuits when ``chromium_installed()`` is already True. Emits 

253 ``setup_start`` before spawning, ``setup_progress`` for each recognizable 

254 progress line on stdout, and ``setup_done`` on exit (``success=False`` plus 

255 the subprocess stderr tail on failure). Raises :class:`CrawlerBrowserError` 

256 with the tail so task workers route to FAILED cleanly. 

257 """ 

258 if chromium_installed(): 

259 _emit_setup_done(on_progress, success=True, error=None) 

260 return 

261 

262 # Two callers (a second POST /setup/crawler, or a crawl bootstrapping on 

263 # first use) would unpack a browser bundle into the same directory and 

264 # corrupt it. On disk, not in memory, because the CLI and MCP are separate 

265 # processes sharing this path. thread_local=False because the acquire runs 

266 # in a worker thread and the release on the loop thread; with the default 

267 # the release would not count and the lock would never be freed. 

268 lock = FileLock(str(_bootstrap_lock_path()), thread_local=False) 

269 try: 

270 await asyncio.to_thread(lock.acquire, timeout=_BOOTSTRAP_LOCK_TIMEOUT_S) 

271 except FileLockTimeout as exc: 

272 message = ( 

273 "Timed out waiting for another Chromium install to finish. " 

274 "If no other lilbee process is installing, retry." 

275 ) 

276 _emit_setup_done(on_progress, success=False, error=message) 

277 raise CrawlerBrowserError(message) from exc 

278 try: 

279 # The install we were queued behind may have been the one we needed. 

280 if chromium_installed(): 

281 _emit_setup_done(on_progress, success=True, error=None) 

282 return 

283 await _install_chromium(on_progress) 

284 finally: 

285 lock.release() 

286 

287 

288async def _install_chromium(on_progress: DetailedProgressCallback | None) -> None: 

289 """Run the install subprocess. Caller holds the bootstrap lock.""" 

290 _emit_setup_start(on_progress) 

291 

292 try: 

293 runner, runner_env = _resolve_playwright_runner() 

294 except CrawlerBrowserError as exc: 

295 _emit_setup_done(on_progress, success=False, error=str(exc)) 

296 raise 

297 

298 proc = await asyncio.create_subprocess_exec( 

299 *runner, 

300 "install", 

301 "chromium", 

302 stdout=asyncio.subprocess.PIPE, 

303 stderr=asyncio.subprocess.PIPE, 

304 env=runner_env, 

305 ) 

306 # mypy narrowing: asyncio.create_subprocess_exec with PIPE guarantees 

307 # non-None streams at runtime; the asserts only satisfy the type checker. 

308 assert proc.stdout is not None # noqa: S101 

309 assert proc.stderr is not None # noqa: S101 

310 

311 stderr_tail: list[str] = [] 

312 try: 

313 await asyncio.gather( 

314 _drain_stdout_to_progress(proc.stdout, on_progress), 

315 _drain_stderr(proc.stderr, stderr_tail), 

316 ) 

317 returncode = await proc.wait() 

318 finally: 

319 # On cancel (SSE client disconnect) or any error, don't leave the 

320 # ~180MB chromium install running orphaned; terminate, then kill. 

321 await _terminate_process(proc) 

322 

323 if returncode != 0: 

324 tail = "\n".join(stderr_tail[-10:]) or f"exit code {returncode}" 

325 _emit_setup_done(on_progress, success=False, error=tail) 

326 raise CrawlerBrowserError(f"Chromium bootstrap failed (exit {returncode}): {tail}") 

327 

328 _emit_setup_done(on_progress, success=True, error=None) 

329 

330 

331_TERMINATE_TIMEOUT_S = 5.0 

332 

333 

334async def _terminate_process(proc: asyncio.subprocess.Process) -> None: 

335 """Terminate then kill *proc* if it is still running, and reap it. 

336 

337 Best-effort and re-cancel-safe: the waits are shielded so a second 

338 cancellation while unwinding cannot leave the child orphaned. 

339 """ 

340 if proc.returncode is not None: 

341 return 

342 with contextlib.suppress(ProcessLookupError): 

343 proc.terminate() 

344 with contextlib.suppress(TimeoutError, asyncio.CancelledError): 

345 await asyncio.wait_for(asyncio.shield(proc.wait()), timeout=_TERMINATE_TIMEOUT_S) 

346 if proc.returncode is None: 

347 with contextlib.suppress(ProcessLookupError): 

348 proc.kill() 

349 # SIGKILL is uncatchable so the reap should be near-instant; cap it anyway 

350 # so a wedged reap can't hang the cleanup path indefinitely. 

351 with contextlib.suppress(TimeoutError, asyncio.CancelledError): 

352 await asyncio.wait_for(asyncio.shield(proc.wait()), timeout=_TERMINATE_TIMEOUT_S)