mirror of
https://git.zavage.net/Zavage-Software/wabot.git
synced 2026-07-21 13:06:08 -06:00
feat: ManagedService spawns detached driver services that outlive python
This commit is contained in:
parent
e9d307e020
commit
f82b77ae62
@ -11,8 +11,16 @@ from __future__ import annotations
|
|||||||
import http.client
|
import http.client
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import platformdirs
|
||||||
from selenium import webdriver
|
from selenium import webdriver
|
||||||
|
|
||||||
LOGGER = logging.getLogger("wabot")
|
LOGGER = logging.getLogger("wabot")
|
||||||
@ -54,8 +62,6 @@ def build_options(
|
|||||||
|
|
||||||
|
|
||||||
def _chromium_binary() -> str | None:
|
def _chromium_binary() -> str | None:
|
||||||
import shutil
|
|
||||||
|
|
||||||
for name in ("chromium", "chromium-browser", "google-chrome", "chrome"):
|
for name in ("chromium", "chromium-browser", "google-chrome", "chrome"):
|
||||||
found = shutil.which(name)
|
found = shutil.which(name)
|
||||||
if found:
|
if found:
|
||||||
@ -88,3 +94,90 @@ class ExternalServer:
|
|||||||
if not service_alive(self.url):
|
if not service_alive(self.url):
|
||||||
raise ConnectionError(f"no WebDriver server answering at {self.url}/status")
|
raise ConnectionError(f"no WebDriver server answering at {self.url}/status")
|
||||||
return self.url
|
return self.url
|
||||||
|
|
||||||
|
|
||||||
|
def find_driver_binary(browser: str) -> str:
|
||||||
|
"""Locate chromedriver/geckodriver: env override, PATH, then Selenium Manager."""
|
||||||
|
name = DRIVER_BINARIES[normalize_browser(browser)]
|
||||||
|
override = os.environ.get(f"WABOT_{name.upper()}")
|
||||||
|
if override:
|
||||||
|
return override
|
||||||
|
found = shutil.which(name)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
try:
|
||||||
|
# Selenium Manager is bundled with selenium but explicitly beta
|
||||||
|
# ("may change") — keep it as a guarded fallback, never the primary.
|
||||||
|
from selenium.webdriver.common.selenium_manager import SeleniumManager
|
||||||
|
|
||||||
|
args = ["--browser", "chrome" if browser == "chromium" else browser]
|
||||||
|
chromium = _chromium_binary()
|
||||||
|
if browser == "chromium" and chromium:
|
||||||
|
args += ["--browser-path", chromium]
|
||||||
|
return SeleniumManager().binary_paths(args)["driver_path"]
|
||||||
|
except Exception as ex: # noqa: BLE001 — beta API, failure modes unknown
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"could not find {name!r} on PATH (set WABOT_{name.upper()} to override); "
|
||||||
|
f"Selenium Manager fallback also failed: {ex}"
|
||||||
|
) from ex
|
||||||
|
|
||||||
|
|
||||||
|
def _free_port() -> int:
|
||||||
|
with socket.socket() as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
return sock.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedService:
|
||||||
|
"""A driver service wabot spawns detached, so it outlives this process."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
browser: str,
|
||||||
|
log_dir: Path | None = None,
|
||||||
|
driver_binary: str | None = None,
|
||||||
|
startup_timeout: float = 15.0,
|
||||||
|
):
|
||||||
|
self.browser = normalize_browser(browser)
|
||||||
|
self.log_dir = Path(log_dir) if log_dir else Path(platformdirs.user_data_dir("wabot"))
|
||||||
|
self.driver_binary = driver_binary
|
||||||
|
self.startup_timeout = startup_timeout
|
||||||
|
self.pid: int | None = None
|
||||||
|
self.port: int | None = None
|
||||||
|
|
||||||
|
def ensure_running(self) -> str:
|
||||||
|
binary = self.driver_binary or find_driver_binary(self.browser)
|
||||||
|
self.port = _free_port()
|
||||||
|
url = f"http://127.0.0.1:{self.port}"
|
||||||
|
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path = self.log_dir / f"{DRIVER_BINARIES[self.browser]}-{self.port}.log"
|
||||||
|
with open(log_path, "ab") as log:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[binary, f"--port={self.port}"],
|
||||||
|
stdout=log,
|
||||||
|
stderr=log,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
start_new_session=True, # own session: survives this python process
|
||||||
|
)
|
||||||
|
self.pid = process.pid
|
||||||
|
LOGGER.info("spawned %s (pid %s) on %s, log %s", binary, self.pid, url, log_path)
|
||||||
|
deadline = time.monotonic() + self.startup_timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if service_alive(url):
|
||||||
|
return url
|
||||||
|
if process.poll() is not None:
|
||||||
|
break # exited early; fall through to the error
|
||||||
|
time.sleep(0.2)
|
||||||
|
raise TimeoutError(
|
||||||
|
f"{binary} did not answer /status on {url} within "
|
||||||
|
f"{self.startup_timeout}s (see {log_path})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_service(pid: int) -> bool:
|
||||||
|
"""SIGTERM a managed driver service. False if it was already gone."""
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
return True
|
||||||
|
except (ProcessLookupError, PermissionError):
|
||||||
|
return False
|
||||||
|
|||||||
@ -1,10 +1,22 @@
|
|||||||
import http.server
|
import http.server
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from wabot.hosts import ExternalServer, build_options, normalize_browser, service_alive
|
from wabot.hosts import (
|
||||||
|
ExternalServer,
|
||||||
|
ManagedService,
|
||||||
|
build_options,
|
||||||
|
find_driver_binary,
|
||||||
|
normalize_browser,
|
||||||
|
service_alive,
|
||||||
|
stop_service,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestNormalizeBrowser:
|
class TestNormalizeBrowser:
|
||||||
@ -119,3 +131,74 @@ class TestServiceAliveNonHttpResponder:
|
|||||||
ExternalServer(f"http://127.0.0.1:{port}").ensure_running()
|
ExternalServer(f"http://127.0.0.1:{port}").ensure_running()
|
||||||
finally:
|
finally:
|
||||||
server.close()
|
server.close()
|
||||||
|
|
||||||
|
|
||||||
|
STUB_DRIVER = """\
|
||||||
|
#!{python}
|
||||||
|
import http.server, json, sys
|
||||||
|
port = int(sys.argv[1].split("=", 1)[1])
|
||||||
|
class H(http.server.BaseHTTPRequestHandler):
|
||||||
|
def do_GET(self):
|
||||||
|
body = json.dumps({{"value": {{"ready": True, "message": ""}}}}).encode()
|
||||||
|
self.send_response(200); self.end_headers(); self.wfile.write(body)
|
||||||
|
def log_message(self, *a): pass
|
||||||
|
http.server.HTTPServer(("127.0.0.1", port), H).serve_forever()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stub_driver(tmp_path):
|
||||||
|
path = tmp_path / "chromedriver"
|
||||||
|
path.write_text(STUB_DRIVER.format(python=sys.executable))
|
||||||
|
path.chmod(path.stat().st_mode | stat.S_IEXEC)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindDriverBinary:
|
||||||
|
def test_env_var_override_wins(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("WABOT_CHROMEDRIVER", "/custom/chromedriver")
|
||||||
|
assert find_driver_binary("chromium") == "/custom/chromedriver"
|
||||||
|
|
||||||
|
def test_path_lookup(self, monkeypatch, stub_driver):
|
||||||
|
monkeypatch.delenv("WABOT_CHROMEDRIVER", raising=False)
|
||||||
|
monkeypatch.setenv("PATH", str(stub_driver.parent))
|
||||||
|
assert find_driver_binary("chromium") == str(stub_driver)
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagedService:
|
||||||
|
def test_spawns_detached_and_answers_status(self, stub_driver, tmp_path):
|
||||||
|
svc = ManagedService("chromium", log_dir=tmp_path, driver_binary=str(stub_driver))
|
||||||
|
url = svc.ensure_running()
|
||||||
|
try:
|
||||||
|
assert service_alive(url)
|
||||||
|
assert svc.pid is not None and svc.port is not None
|
||||||
|
assert url == f"http://127.0.0.1:{svc.port}"
|
||||||
|
# detached: the spawned process is in its own session, so it
|
||||||
|
# would survive this python process exiting
|
||||||
|
assert os.getsid(svc.pid) != os.getsid(os.getpid())
|
||||||
|
assert (tmp_path / f"chromedriver-{svc.port}.log").exists()
|
||||||
|
finally:
|
||||||
|
stop_service(svc.pid)
|
||||||
|
|
||||||
|
def test_startup_timeout_raises(self, tmp_path):
|
||||||
|
# /bin/true exits immediately and never serves /status
|
||||||
|
svc = ManagedService(
|
||||||
|
"chromium", log_dir=tmp_path, driver_binary="/bin/true", startup_timeout=1.0
|
||||||
|
)
|
||||||
|
with pytest.raises(TimeoutError, match="did not answer /status"):
|
||||||
|
svc.ensure_running()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStopService:
|
||||||
|
def test_stops_a_live_process_and_reports_dead_ones(self, stub_driver, tmp_path):
|
||||||
|
svc = ManagedService("chromium", log_dir=tmp_path, driver_binary=str(stub_driver))
|
||||||
|
svc.ensure_running()
|
||||||
|
assert stop_service(svc.pid) is True
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
try:
|
||||||
|
os.kill(svc.pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
break
|
||||||
|
time.sleep(0.05)
|
||||||
|
assert stop_service(999999) is False # no such pid
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user