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

183 statements  

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

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

2 

3from __future__ import annotations 

4 

5import asyncio 

6import contextlib 

7import os 

8import re 

9import shlex 

10import sys 

11from pathlib import Path 

12 

13from filelock import FileLock 

14from filelock import Timeout as FileLockTimeout 

15 

16from lilbee._frozen import is_frozen 

17from lilbee.runtime.progress import ( 

18 DetailedProgressCallback, 

19 EventType, 

20 SetupDoneEvent, 

21 SetupProgressEvent, 

22 SetupStartEvent, 

23) 

24 

25 

26class CrawlerBrowserError(RuntimeError): 

27 """A browser-mode crawl cannot start its browser.""" 

28 

29 

30class ChromiumMissingError(CrawlerBrowserError): 

31 """The headless Chromium shell a browser-mode crawl launches is not on disk.""" 

32 

33 

34class CrawlEngineRefusedError(RuntimeError): 

35 """crawlberg refuses to build the engine for a crawl.""" 

36 

37 

38class CrawlerBackendError(RuntimeError): 

39 """The ``crawler`` extra was never installed.""" 

40 

41 

42_CHROMIUM_COMPONENT = "chromium" 

43# Playwright's name for the headless shell in browsers.json, and its cache directory prefix. 

44_HEADLESS_SHELL_BROWSER = "chromium-headless-shell" 

45_HEADLESS_SHELL_DIR_PREFIX = "chromium_headless_shell-" 

46# The file Playwright writes into a browser directory once its install has finished. 

47_INSTALL_COMPLETE_MARKER = "INSTALLATION_COMPLETE" 

48# File names Playwright gives the headless shell executable on each platform. 

49_HEADLESS_SHELL_NAMES = frozenset( 

50 {"chrome-headless-shell", "chrome-headless-shell.exe", "headless_shell"} 

51) 

52 

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

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

55_BOOTSTRAP_LOCK_TIMEOUT_S = 900.0 

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

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

58# 'Total bytes' parses out of stdout. 

59_CHROMIUM_ESTIMATE_MB = 180 

60_CHROMIUM_SIZE_ESTIMATE_BYTES = _CHROMIUM_ESTIMATE_MB * 1024 * 1024 

61 

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

63_BYTE_UNIT_SCALE: dict[str, int] = { 

64 "b": 1, 

65 "kb": 1024, 

66 "kib": 1024, 

67 "mb": 1024 * 1024, 

68 "mib": 1024 * 1024, 

69} 

70 

71# Playwright 1.58 prints lines like 

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

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

74_PROGRESS_LINE_RE = re.compile( 

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

76 re.IGNORECASE, 

77) 

78 

79 

80def _browsers_cache_path() -> Path: 

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

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

83 if override: 

84 return Path(override).expanduser() 

85 if sys.platform == "darwin": 

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

87 if sys.platform == "win32": 

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

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

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

91 

92 

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

94 """Pull the headless shell revision out of a Playwright ``browsers.json``.""" 

95 import json 

96 

97 try: 

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

99 except (OSError, ValueError): 

100 return None 

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

102 if browser.get("name") == _HEADLESS_SHELL_BROWSER: 

103 revision = browser.get("revision") 

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

105 return None 

106 

107 

108def _expected_chromium_revision() -> str | None: 

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

110 

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

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

113 """ 

114 try: 

115 import playwright as _pw 

116 except ImportError: 

117 return None 

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

119 return _read_chromium_revision(path) 

120 return None 

121 

122 

123def _shell_in(directory: Path) -> Path | None: 

124 """The headless shell path under *directory*, if Playwright finished installing it. 

125 

126 A path that cannot run counts, so a crawl reports it and installs nothing over it. 

127 """ 

128 if not (directory / _INSTALL_COMPLETE_MARKER).is_file(): 

129 return None 

130 matches = (path for path in sorted(directory.rglob("*")) if path.name in _HEADLESS_SHELL_NAMES) 

131 return next(matches, None) 

132 

133 

134def headless_shell_executable() -> Path | None: 

135 """The headless shell binary at the revision Playwright expects, or None if absent. 

136 

137 With the revision unknown, any installed revision counts. 

138 """ 

139 root = _browsers_cache_path() 

140 if not root.is_dir(): 

141 return None 

142 expected = _expected_chromium_revision() 

143 if expected is None: 

144 candidates = sorted(root.glob(f"{_HEADLESS_SHELL_DIR_PREFIX}*")) 

145 else: 

146 candidates = [root / f"{_HEADLESS_SHELL_DIR_PREFIX}{expected}"] 

147 for directory in candidates: 

148 executable = _shell_in(directory) 

149 if executable is not None: 

150 return executable 

151 return None 

152 

153 

154def chromium_installed() -> bool: 

155 """Return True if the headless shell a browser-mode crawl launches is on disk.""" 

156 return headless_shell_executable() is not None 

157 

158 

159def _bootstrap_lock_path() -> Path: 

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

161 root = _browsers_cache_path() 

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

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

164 

165 

166def crawler_browsers_path() -> Path: 

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

168 

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

170 lives. The underlying resolver stays private because callers should 

171 not depend on the Playwright-specific directory layout. 

172 """ 

173 return _browsers_cache_path() 

174 

175 

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

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

178 

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

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

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

182 have to handle a missing total. 

183 """ 

184 match = _PROGRESS_LINE_RE.search(line) 

185 if match is None: 

186 return None 

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

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

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

190 scale = _BYTE_UNIT_SCALE.get(unit, 1) 

191 total = int(raw_total * scale) 

192 downloaded = int(total * pct / 100) 

193 return downloaded, total 

194 

195 

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

197 if on_progress is None: 

198 return 

199 on_progress( 

200 EventType.SETUP_START, 

201 SetupStartEvent( 

202 component=_CHROMIUM_COMPONENT, 

203 size_estimate_bytes=_CHROMIUM_SIZE_ESTIMATE_BYTES, 

204 ), 

205 ) 

206 

207 

208def _emit_setup_done( 

209 on_progress: DetailedProgressCallback | None, 

210 *, 

211 success: bool, 

212 error: str | None, 

213) -> None: 

214 if on_progress is None: 

215 return 

216 on_progress( 

217 EventType.SETUP_DONE, 

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

219 ) 

220 

221 

222async def _drain_stdout_to_progress( 

223 stream: asyncio.StreamReader, 

224 on_progress: DetailedProgressCallback | None, 

225) -> None: 

226 while True: 

227 line_bytes = await stream.readline() 

228 if not line_bytes: 

229 return 

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

231 parsed = _bytes_from_stdout(line) 

232 if parsed is None or on_progress is None: 

233 continue 

234 downloaded, total = parsed 

235 on_progress( 

236 EventType.SETUP_PROGRESS, 

237 SetupProgressEvent( 

238 component=_CHROMIUM_COMPONENT, 

239 downloaded_bytes=downloaded, 

240 total_bytes=total, 

241 detail=line, 

242 ), 

243 ) 

244 

245 

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

247 while True: 

248 line_bytes = await stream.readline() 

249 if not line_bytes: 

250 return 

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

252 

253 

254_PLAYWRIGHT_MISSING_HINT = ( 

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

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

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

258 "release binary." 

259) 

260 

261 

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

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

264 

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

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

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

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

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

270 """ 

271 try: 

272 from playwright._impl._driver import compute_driver_executable, get_driver_env 

273 except ImportError as exc: 

274 raise CrawlerBrowserError(_PLAYWRIGHT_MISSING_HINT) from exc 

275 try: 

276 driver_exe, driver_cli = compute_driver_executable() 

277 except Exception: 

278 if not is_frozen(): 

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

280 raise 

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

282 

283 

284def install_deps_command() -> str: 

285 """The shell command that installs the system libraries Chromium needs. 

286 

287 Built from the same runner as ``playwright install``, so it runs on a frozen 

288 build and on a tool install, where no ``playwright`` is on PATH. Raises 

289 :class:`CrawlerBrowserError` when no runner is available. 

290 """ 

291 argv, _env = _resolve_playwright_runner() 

292 return shlex.join([*argv, "install-deps", "chromium"]) 

293 

294 

295async def bootstrap_chromium( 

296 on_progress: DetailedProgressCallback | None = None, 

297) -> None: 

298 """Run ``playwright install --only-shell chromium`` as a subprocess, emitting events. 

299 

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

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

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

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

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

305 """ 

306 if chromium_installed(): 

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

308 return 

309 

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

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

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

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

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

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

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

317 try: 

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

319 except FileLockTimeout as exc: 

320 message = ( 

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

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

323 ) 

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

325 raise CrawlerBrowserError(message) from exc 

326 try: 

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

328 if chromium_installed(): 

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

330 return 

331 await _install_chromium(on_progress) 

332 finally: 

333 lock.release() 

334 

335 

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

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

338 _emit_setup_start(on_progress) 

339 

340 try: 

341 runner, runner_env = _resolve_playwright_runner() 

342 except CrawlerBrowserError as exc: 

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

344 raise 

345 

346 proc = await asyncio.create_subprocess_exec( 

347 *runner, 

348 "install", 

349 "--only-shell", 

350 "chromium", 

351 stdout=asyncio.subprocess.PIPE, 

352 stderr=asyncio.subprocess.PIPE, 

353 env=runner_env, 

354 ) 

355 # mypy narrowing: asyncio.create_subprocess_exec with PIPE guarantees 

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

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

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

359 

360 stderr_tail: list[str] = [] 

361 try: 

362 await asyncio.gather( 

363 _drain_stdout_to_progress(proc.stdout, on_progress), 

364 _drain_stderr(proc.stderr, stderr_tail), 

365 ) 

366 returncode = await proc.wait() 

367 finally: 

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

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

370 await _terminate_process(proc) 

371 

372 if returncode != 0: 

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

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

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

376 

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

378 

379 

380_TERMINATE_TIMEOUT_S = 5.0 

381 

382 

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

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

385 

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

387 cancellation while unwinding cannot leave the child orphaned. 

388 """ 

389 if proc.returncode is not None: 

390 return 

391 with contextlib.suppress(ProcessLookupError): 

392 proc.terminate() 

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

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

395 if proc.returncode is None: 

396 with contextlib.suppress(ProcessLookupError): 

397 proc.kill() 

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

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

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

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