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