|
| 1 | +"""Helpers for spawning a production taskdog-server subprocess in E2E tests. |
| 2 | +
|
| 3 | +Kept out of conftest.py so both the fixtures and individual test modules can |
| 4 | +import spawn_server without relying on pytest's conftest import machinery. |
| 5 | +""" |
| 6 | + |
| 7 | +from __future__ import annotations |
| 8 | + |
| 9 | +import os |
| 10 | +import socket |
| 11 | +import subprocess |
| 12 | +import sys |
| 13 | +import time |
| 14 | +from typing import TYPE_CHECKING |
| 15 | + |
| 16 | +from taskdog_client.taskdog_api_client import TaskdogApiClient |
| 17 | + |
| 18 | +if TYPE_CHECKING: |
| 19 | + from pathlib import Path |
| 20 | + |
| 21 | +_READINESS_TIMEOUT_S = 15.0 |
| 22 | + |
| 23 | + |
| 24 | +def _free_port() -> int: |
| 25 | + """Reserve an ephemeral TCP port and release it for the server to bind.""" |
| 26 | + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: |
| 27 | + sock.bind(("127.0.0.1", 0)) |
| 28 | + return int(sock.getsockname()[1]) |
| 29 | + |
| 30 | + |
| 31 | +def _wait_until_ready(base_url: str, process: subprocess.Popen[bytes]) -> None: |
| 32 | + """Poll /health until the server responds or the timeout elapses.""" |
| 33 | + probe = TaskdogApiClient(base_url=base_url) |
| 34 | + deadline = time.monotonic() + _READINESS_TIMEOUT_S |
| 35 | + try: |
| 36 | + while time.monotonic() < deadline: |
| 37 | + if process.poll() is not None: |
| 38 | + raise RuntimeError( |
| 39 | + f"server exited early with code {process.returncode}" |
| 40 | + ) |
| 41 | + if probe.check_health(): |
| 42 | + return |
| 43 | + time.sleep(0.1) |
| 44 | + raise RuntimeError("server did not become healthy in time") |
| 45 | + finally: |
| 46 | + probe.close() |
| 47 | + |
| 48 | + |
| 49 | +def spawn_server( |
| 50 | + db_path: Path, cfg_dir: Path, *, auth: bool |
| 51 | +) -> tuple[subprocess.Popen[bytes], str]: |
| 52 | + """Launch a taskdog-server subprocess and wait until it is healthy. |
| 53 | +
|
| 54 | + Args: |
| 55 | + db_path: SQLite file the server should use. |
| 56 | + cfg_dir: Empty XDG_CONFIG_HOME so host config never leaks in. |
| 57 | + auth: When True, enable auth via env override. |
| 58 | +
|
| 59 | + Returns: |
| 60 | + (process, base_url). |
| 61 | + """ |
| 62 | + port = _free_port() |
| 63 | + env = { |
| 64 | + **os.environ, |
| 65 | + "TASKDOG_STORAGE_DATABASE_URL": f"sqlite:///{db_path}", |
| 66 | + "XDG_CONFIG_HOME": str(cfg_dir), |
| 67 | + } |
| 68 | + if auth: |
| 69 | + env["TASKDOG_AUTH_ENABLED"] = "true" |
| 70 | + # Redirect server output to a file rather than a PIPE: an unread PIPE |
| 71 | + # deadlocks the server once the OS buffer fills with access logs, while |
| 72 | + # DEVNULL would discard the diagnostics we need when startup fails. |
| 73 | + log_path = db_path.parent / "server.log" |
| 74 | + with log_path.open("w") as log_file: |
| 75 | + process = subprocess.Popen( |
| 76 | + [ |
| 77 | + sys.executable, |
| 78 | + "-m", |
| 79 | + "taskdog_server.main", |
| 80 | + "--host", |
| 81 | + "127.0.0.1", |
| 82 | + "--port", |
| 83 | + str(port), |
| 84 | + ], |
| 85 | + env=env, |
| 86 | + stdout=log_file, |
| 87 | + stderr=subprocess.STDOUT, |
| 88 | + ) |
| 89 | + base_url = f"http://127.0.0.1:{port}" |
| 90 | + try: |
| 91 | + _wait_until_ready(base_url, process) |
| 92 | + except Exception as exc: |
| 93 | + if process.poll() is None: |
| 94 | + terminate_server(process) |
| 95 | + raise RuntimeError( |
| 96 | + f"taskdog-server failed to start (log at {log_path}):\n" |
| 97 | + f"{log_path.read_text()[-2000:]}" |
| 98 | + ) from exc |
| 99 | + return process, base_url |
| 100 | + |
| 101 | + |
| 102 | +def terminate_server(process: subprocess.Popen[bytes]) -> None: |
| 103 | + """Terminate a spawned server process, escalating to kill if needed.""" |
| 104 | + process.terminate() |
| 105 | + try: |
| 106 | + process.wait(timeout=5) |
| 107 | + except subprocess.TimeoutExpired: |
| 108 | + process.kill() |
| 109 | + process.wait() |
0 commit comments