okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petfix(browser): prefer pinned headless shell for automationf67dd6d

main
17.5 KiB469 linesraw
1"""Explicit Chrome process ownership; no CLI, driver, or second event loop."""
2
3from __future__ import annotations
4
5import asyncio
6import atexit
7from collections.abc import Awaitable, Sequence
8import os
9from pathlib import Path
10import re
11import shutil
12import signal
13import subprocess
14import tempfile
15from typing import BinaryIO, TYPE_CHECKING, TypeVar
16
17from .errors import LaunchError
18from .transport import positive
19
20if TYPE_CHECKING:
21    from .session import Browser
22
23_T = TypeVar("_T")
24_MARKER = ".albedo-browser-profile"
25_LOCK = ".albedo-browser-launch.lock"
26# One strong reference per live owned process, including deliberately disconnected ones.
27# A normal interpreter exit gets a best-effort cleanup. SIGKILL cannot run atexit.
28_LIVE: set[OwnedChrome] = set()
29
30
31async def _finish_cleanup(operation: Awaitable[_T]) -> _T:
32    """Finish bounded cleanup before propagating a caller's (even repeated) cancel."""
33    task = asyncio.ensure_future(operation)
34    interrupted = False
35    while True:
36        try:
37            result = await asyncio.shield(task)
38            break
39        except asyncio.CancelledError:
40            if task.cancelled():
41                raise
42            interrupted = True
43            if task.done():
44                # Retrieve exceptions too, rather than abandoning a finished task.
45                result = task.result()
46                break
47    if interrupted:
48        raise asyncio.CancelledError
49    return result
50
51
52def _executable(requested: str | os.PathLike[str] | None, *, headless: bool) -> str:
53    explicit = (
54        requested
55        or os.environ.get("ALBEDO_BROWSER_EXECUTABLE")
56        or os.environ.get("PRIME_BROWSER_EXECUTABLE")
57        or os.environ.get("CHROME_PATH")
58    )
59    if explicit:
60        candidate = os.path.expanduser(os.fspath(explicit))
61        found = shutil.which(candidate)
62        if found:
63            return found
64        raise LaunchError(
65            "Chrome executable was not found or is not executable.",
66            reason="executable_not_found",
67            executable=candidate,
68            recovery='Pass executable="/path/to/chrome" or install Chrome/Chromium in the kernel environment.',
69        )
70    if headless:
71        home = Path(os.environ.get("ALBEDO_HOME") or "~/.albedo").expanduser()
72        # Full Chrome's headless launches still leave macOS recent-app Dock tiles.
73        for candidate in (
74            "chrome-headless-shell",
75            str(home / "browsers/chrome-headless-shell"),
76        ):
77            if found := shutil.which(candidate):
78                # The shell locates ICU and other resources beside its executable.
79                return str(Path(found).resolve())
80    for name in (
81        "chromium",
82        "chromium-browser",
83        "google-chrome",
84        "google-chrome-stable",
85        "chrome",
86    ):
87        found = shutil.which(name)
88        if found:
89            return found
90    candidates: list[Path] = []
91    for root in (Path("/Applications"), Path.home() / "Applications"):
92        for app, binary in (
93            ("Google Chrome", "Google Chrome"),
94            ("Chromium", "Chromium"),
95            ("Google Chrome for Testing", "Google Chrome for Testing"),
96        ):
97            candidates.append(root / f"{app}.app/Contents/MacOS" / binary)
98    for variable in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
99        root = os.environ.get(variable)
100        if root:
101            candidates.extend(
102                (
103                    Path(root) / "Google/Chrome/Application/chrome.exe",
104                    Path(root) / "Chromium/Application/chrome.exe",
105                )
106            )
107    for path in candidates:
108        if path.is_file() and os.access(path, os.X_OK):
109            return str(path)
110    raise LaunchError(
111        "Chrome/Chromium was not found. spawn() does not download a browser.",
112        reason="executable_not_found",
113        recovery='Install Chrome/Chromium, or pass executable="/path/to/chrome".',
114    )
115
116
117def _extra_args(args: Sequence[str]) -> list[str]:
118    if isinstance(args, (str, bytes)):
119        raise ValueError(
120            "args must be a sequence of separate --flag strings, not a shell command"
121        )
122    reserved = {
123        "--user-data-dir",
124        "--profile-directory",
125        "--headless",
126        "--no-sandbox",
127        "--disable-setuid-sandbox",
128    }
129    result = []
130    for arg in args:
131        if (
132            not isinstance(arg, str)
133            or not arg.startswith("--")
134            or arg == "--"
135            or "\x00" in arg
136        ):
137            raise ValueError(
138                "Each extra argument must be a separate --flag string; no positional URLs"
139            )
140        key = arg.split("=", 1)[0]
141        if key in reserved or key.startswith("--remote-debugging"):
142            raise ValueError(
143                f"{key} is managed by spawn(); use its named options instead"
144            )
145        result.append(arg)
146    return result
147
148
149class OwnedChrome:
150    """Internal lifecycle object; Browser delegates to it only after successful spawn."""
151
152    def __init__(self, profile: str | os.PathLike[str] | None) -> None:
153        self.temporary = profile is None
154        self.profile = (
155            Path(tempfile.mkdtemp(prefix="prime-browser-"))
156            if profile is None
157            else Path(profile).expanduser().resolve()
158        )
159        self.process: subprocess.Popen[bytes] | None = None
160        self.log: BinaryIO | None = None
161        self._lock_fd: int | None = None
162        self._released = False
163        try:
164            self.profile.mkdir(parents=True, exist_ok=True, mode=0o700)
165            # O_EXCL closes the race between two spawn() calls using the same profile.
166            try:
167                self._lock_fd = os.open(
168                    self.profile / _LOCK, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600
169                )
170                os.write(self._lock_fd, f"kernel_pid={os.getpid()}\n".encode())
171            except FileExistsError as exc:
172                raise LaunchError(
173                    "This profile is already reserved by another spawn().",
174                    reason="profile_in_use",
175                    profile=str(self.profile),
176                    recovery="Use a different profile, or close its owning Browser. Do not delete a live lock.",
177                ) from exc
178            marker = self.profile / _MARKER
179            if not marker.is_file() and any(
180                p.name != _LOCK for p in self.profile.iterdir()
181            ):
182                raise LaunchError(
183                    "Refusing a nonempty profile not created for this skill.",
184                    reason="unsafe_profile",
185                    profile=str(self.profile),
186                    recovery="Use a new empty directory, not your normal browser profile.",
187                )
188            # lexists notices dangling singleton symlinks too: don't guess whether a
189            # previous Chrome crashed, or attach to somebody else's running process.
190            if any(
191                os.path.lexists(self.profile / name)
192                for name in ("SingletonLock", "SingletonSocket")
193            ):
194                raise LaunchError(
195                    "Chrome has a singleton lock in this profile.",
196                    reason="profile_in_use",
197                    profile=str(self.profile),
198                )
199            marker.touch(mode=0o600, exist_ok=True)
200            # After acquiring our exclusive lock, remove only stale discovery metadata.
201            (self.profile / "DevToolsActivePort").unlink(missing_ok=True)
202            self.log = tempfile.TemporaryFile(mode="w+b", prefix="prime-browser-log-")
203        except BaseException:
204            self._release()
205            raise
206
207    def start(
208        self, executable: str, *, headless: bool, sandbox: bool, args: list[str]
209    ) -> None:
210        command = [
211            executable,
212            f"--user-data-dir={self.profile}",
213            "--remote-debugging-address=127.0.0.1",
214            "--remote-debugging-port=0",
215            "--no-first-run",
216            "--no-default-browser-check",
217        ]
218        if headless:
219            command.extend(["--headless=new", "--disable-dev-shm-usage"])
220        if not sandbox:
221            command.append("--no-sandbox")
222        command.extend(args)
223        command.append("about:blank")
224        # Intentionally no await between process creation and taking ownership. A
225        # cancelled create_subprocess_exec() can otherwise lose its returned handle.
226        # Output goes to a temporary file: no pipe backpressure or kernel-stdout noise.
227        self.process = subprocess.Popen(
228            command,
229            stdin=subprocess.DEVNULL,
230            stdout=self.log,
231            stderr=self.log,
232            start_new_session=(os.name == "posix"),
233        )
234        _LIVE.add(self)
235
236    async def endpoint(self) -> str:
237        assert self.process is not None
238        active = self.profile / "DevToolsActivePort"
239        while True:
240            code = self.process.poll()
241            if code is not None:
242                raise LaunchError(
243                    "Chrome exited before CDP became ready.",
244                    reason="process_exited",
245                    returncode=code,
246                )
247            try:
248                with active.open("rb") as stream:
249                    lines = stream.read(4096).decode("ascii").splitlines()
250                if len(lines) >= 2 and lines[0].isdigit():
251                    port = int(lines[0])
252                    path = lines[1]
253                    if 1 <= port <= 65535 and re.fullmatch(
254                        r"/devtools/browser/[a-zA-Z0-9._-]+", path
255                    ):
256                        return f"ws://127.0.0.1:{port}{path}"
257            except (FileNotFoundError, UnicodeError):
258                pass  # The file may not exist yet, or Chrome may still be writing it.
259            await asyncio.sleep(0.05)
260
261    def stderr_tail(self) -> str:
262        if self.log is None or self.log.closed:
263            return ""
264        self.log.seek(0, os.SEEK_END)
265        self.log.seek(max(0, self.log.tell() - 8192))
266        return self.log.read(8192).decode("utf-8", errors="replace")
267
268    async def wait(self, seconds: float) -> bool:
269        if self.process is None:
270            return True
271        deadline = asyncio.get_running_loop().time() + seconds
272        while self.process.poll() is None:
273            if asyncio.get_running_loop().time() >= deadline:
274                return False
275            await asyncio.sleep(0.05)
276        return True
277
278    def _signal(self, *, kill: bool) -> None:
279        if self.process is None or self.process.poll() is not None:
280            return
281        try:
282            if os.name == "posix":
283                # Chrome children may inherit its process group. Never signal the
284                # kernel's group: start_new_session=True gave this launch its own.
285                os.killpg(self.process.pid, signal.SIGKILL if kill else signal.SIGTERM)
286            elif kill:
287                self.process.kill()
288            else:
289                self.process.terminate()
290        except ProcessLookupError:
291            pass
292
293    def _release(self) -> None:
294        if self._released:
295            return
296        if self.process is not None and self.process.poll() is None:
297            raise LaunchError(
298                "Chrome is still alive; keeping its profile and lock.",
299                reason="cleanup_failed",
300                pid=self.process.pid,
301                profile=str(self.profile),
302            )
303        if self.log is not None:
304            self.log.close()
305        if self._lock_fd is not None:
306            os.close(self._lock_fd)
307            self._lock_fd = None
308            (self.profile / _LOCK).unlink(missing_ok=True)
309        if self.temporary:
310            try:
311                shutil.rmtree(self.profile)
312            except FileNotFoundError:
313                pass
314        self._released = True
315        _LIVE.discard(self)
316
317    async def stop(self) -> None:
318        if self._released:
319            return
320        self._signal(kill=False)
321        if not await self.wait(3):
322            self._signal(kill=True)
323            if not await self.wait(3):
324                raise LaunchError(
325                    "Chrome did not exit after termination.",
326                    reason="cleanup_failed",
327                    pid=self.process.pid if self.process else None,
328                    profile=str(self.profile),
329                )
330        # Chrome's children may finish touching the profile just after the parent
331        # exits. Retry only directory removal, not process creation or browser input.
332        for attempt in range(5):
333            try:
334                self._release()
335                return
336            except OSError as exc:
337                if attempt == 4:
338                    raise LaunchError(
339                        "Chrome stopped, but profile cleanup failed.",
340                        reason="cleanup_failed",
341                        profile=str(self.profile),
342                    ) from exc
343                await asyncio.sleep(0.1)
344
345    def exit_cleanup(self) -> None:
346        """Synchronous fallback for normal Python exit, not a hard-kill guarantee."""
347        try:
348            self._signal(kill=False)
349            if self.process is not None:
350                try:
351                    self.process.wait(timeout=1)
352                except subprocess.TimeoutExpired:
353                    self._signal(kill=True)
354                    self.process.wait(timeout=1)
355            self._release()
356        except Exception:
357            pass
358
359
360def _cleanup_at_exit() -> None:
361    for owner in list(_LIVE):
362        owner.exit_cleanup()
363
364
365atexit.register(_cleanup_at_exit)
366
367
368async def spawn(
369    *,
370    executable: str | os.PathLike[str] | None = None,
371    headless: bool = True,
372    profile: str | os.PathLike[str] | None = None,
373    args: Sequence[str] = (),
374    sandbox: bool = True,
375    timeout: float = 20,
376    max_message_bytes: int = 16 * 1024 * 1024,
377) -> Browser:
378    """Start a dedicated Chrome/Chromium and return a connected, process-owning Browser.
379
380    Auto-detects an installed executable; headless launches prefer an installed
381    chrome-headless-shell to avoid macOS Dock tiles. Uses a temporary profile, loopback
382    CDP and an OS-assigned port by default; never downloads a browser or
383    silently disables its sandbox. A supplied profile must be empty or previously
384    created by albedo and is preserved on close. ``Browser.close()`` or
385    ``async with await spawn()`` stops owned Chrome and removes temporary data.
386    ``disconnect()`` deliberately leaves it running until close or normal exit.
387
388    timeout bounds startup and CDP initialization; bounded cleanup may take extra
389    time. Exceptions include a diagnostic log tail. Cancelled startup is cleaned
390    up before cancellation propagates; actions are never retried.
391    """
392    from .session import connect
393
394    positive(timeout, "timeout")
395    if not isinstance(headless, bool) or not isinstance(sandbox, bool):
396        raise ValueError("headless and sandbox must be booleans")
397    if (
398        isinstance(max_message_bytes, bool)
399        or not isinstance(max_message_bytes, int)
400        or max_message_bytes < 1024
401    ):
402        raise ValueError("max_message_bytes must be an integer of at least 1024")
403    extra = _extra_args(args)
404    binary = _executable(executable, headless=headless)
405    if sandbox and hasattr(os, "geteuid") and os.geteuid() == 0:
406        raise LaunchError(
407            "Chrome cannot use its sandbox as root. Run the kernel as an unprivileged user.",
408            reason="root_requires_opt_in",
409            recovery="Only in an externally isolated disposable container, explicitly use sandbox=False.",
410        )
411    owner = OwnedChrome(profile)
412    browser = None
413    try:
414        async with asyncio.timeout(timeout):
415            owner.start(binary, headless=headless, sandbox=sandbox, args=extra)
416            endpoint = await owner.endpoint()
417            browser = await connect(endpoint, max_message_bytes=max_message_bytes)
418            if owner.process is None or owner.process.poll() is not None:
419                raise LaunchError(
420                    "Chrome exited during CDP initialization.", reason="process_exited"
421                )
422            browser._owner = owner
423            return browser
424    except BaseException as exc:
425        tail = owner.stderr_tail()
426        code = owner.process.poll() if owner.process else None
427
428        async def cleanup() -> None:
429            try:
430                if browser is not None:
431                    await browser.disconnect()
432            finally:
433                await owner.stop()
434
435        try:
436            await _finish_cleanup(cleanup())
437        except asyncio.CancelledError:
438            raise
439        except Exception as cleanup_error:
440            exc.add_note(
441                f"Cleanup also failed: {cleanup_error}; profile={owner.profile}"
442            )
443        if isinstance(exc, (asyncio.CancelledError, KeyboardInterrupt, SystemExit)):
444            raise
445        if isinstance(exc, LaunchError):
446            exc.details.update(
447                stderr_tail=tail, executable=binary, profile=str(owner.profile)
448            )
449            raise
450        raise LaunchError(
451            "Chrome startup timed out."
452            if isinstance(exc, TimeoutError)
453            else "Chrome startup failed.",
454            reason="startup_timeout"
455            if isinstance(exc, TimeoutError)
456            else "startup_failed",
457            executable=binary,
458            profile=str(owner.profile),
459            returncode=code,
460            stderr_tail=tail,
461            cause=type(exc).__name__,
462            timeout=timeout,
463        ) from exc
464
465
466async def cleanup_all() -> None:
467    tasks = [chrome.stop() for chrome in list(_LIVE)]
468    if tasks:
469        await asyncio.gather(*tasks, return_exceptions=True)