From c02d66b2d3221c0c5f5e8f2cb2ae218f1e325a0a Mon Sep 17 00:00:00 2001 From: historia Date: Mon, 24 Aug 2026 01:57:13 -0400 Subject: feat: manage venv for all backends --- backends/__init__.py | 41 +++++++-- backends/audiocpp.py | 46 +++++++--- backends/common.py | 19 ++-- backends/envs.py | 185 ++++++++++++++++++++++++++++++++++++++ backends/faster.py | 63 ++++++++----- backends/qwen.py | 41 ++++++--- backends/servers.py | 244 +++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 576 insertions(+), 63 deletions(-) create mode 100644 backends/envs.py create mode 100644 backends/servers.py (limited to 'backends') diff --git a/backends/__init__.py b/backends/__init__.py index 629551e..6a23ab7 100644 --- a/backends/__init__.py +++ b/backends/__init__.py @@ -5,8 +5,15 @@ its setup wizard, its status detection, and the launch command it prints once configured. This package aggregates them into a single registry so ``audiobook.py``'s TUI hub and future tools can iterate backends without hardcoding their names: ``backends.detect_all()`` reports which are set -up (and whether their server is currently running), and -``backends.REGISTRY`` drives the hub's setup/configure menus. +up (and whether their server is currently running), and the registry +drives the hub's setup/configure menus. + +The registry is built lazily on the first call to ``get``/``detect_all``/ +``detect`` (not at package import time), because the backend modules pull +in ``converter.tts`` and its third-party dependencies, which are only +available inside the managed venv that ``audiobook.py`` bootstraps before +importing them. ``backends.envs`` is imported during that bootstrap, so +importing this package must stay cheap and dependency-free. Adding a backend: create ``backends/.py`` exposing ``detect() -> BackendStatus``, ``run_tui() -> int`` and @@ -15,10 +22,25 @@ Adding a backend: create ``backends/.py`` exposing automatically. """ +import shlex from dataclasses import dataclass, field from typing import Callable, List, Optional +@dataclass +class ServerSpec: + """One launchable server process for a backend. + + A backend may expose more than one server (qwen runs CustomVoice and Base + on separate ports). ARGV is the exact command line the hub spawns (using + the managed venv's absolute binaries, so no shell activation is needed); + URL is the endpoint ``common.server_running`` probes to decide readiness. + """ + name: str + url: str + argv: List[str] + + @dataclass class BackendStatus: """How far a backend is set up, plus the command to start it. @@ -29,8 +51,10 @@ class BackendStatus: points at the right port). RUNNING means an external server is currently accepting connections on the configured port (probed by ``backends.common.server_running``). DETAILS are short status lines for - the hub. LAUNCH_HINT is the exact command the user runs to start the - server. + the hub. LAUNCH_HINT is the human-readable command(s) the user runs to + start the server, derived from SERVERS by ``format_launch_hint``. + SERVERS is the machine-usable list of server processes the hub can + start/stop (empty when the backend is not yet configured). """ key: str label: str @@ -39,6 +63,7 @@ class BackendStatus: running: bool = False details: List[str] = field(default_factory=list) launch_hint: str = "" + servers: List[ServerSpec] = field(default_factory=list) @property def ready(self) -> bool: @@ -46,6 +71,11 @@ class BackendStatus: return self.installed and self.configured +def format_launch_hint(servers: List[ServerSpec]) -> str: + """Join a backend's server argvs into a copy-pasteable launch hint.""" + return " ; ".join(shlex.join(s.argv) for s in servers) + + @dataclass class ConfigureAction: """A per-backend "configure" menu entry (e.g. "New server.json").""" @@ -114,6 +144,3 @@ def detect(key: str) -> Optional[BackendStatus]: """Detect a single backend by key.""" info = get(key) return info.detect() if info is not None else None - - -_build_registry() diff --git a/backends/audiocpp.py b/backends/audiocpp.py index 57636a7..486017f 100755 --- a/backends/audiocpp.py +++ b/backends/audiocpp.py @@ -41,24 +41,34 @@ from typing import Callable, Dict, List, Optional, Set, Tuple # Allow running directly (python backends/audiocpp.py) from any cwd. sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from ui import tui -from backends import BackendStatus, ConfigureAction -from backends import common +from backends import ( + BackendStatus, + ConfigureAction, + ServerSpec, + common, + format_launch_hint, +) from backends.common import ( CONFIG_PATH, PROMPT_TEXT_FILENAME, TTS_ROOT, + VOICES_DIR, detect_wav_dir, find_wav_files, normalize_dir_arg, read_prompt_text, resolve_wav_dir_arg, + write_prompt_text, +) +from backends.common import ( wav_dir_info as _wav_dir_info, +) +from backends.common import ( wav_dir_preview as _wav_dir_preview, - write_prompt_text, ) from converter import config from converter.tts import transcribe_reference_audio, whisper_backend_available +from ui import tui DEFAULT_HOST = "127.0.0.1" FALLBACK_PORT = 8080 @@ -1145,7 +1155,7 @@ def _wizard(stdscr, args: argparse.Namespace, parser: argparse.ArgumentParser wav_dir = tui.browse_directory( stdscr, "Select the directory with your .wav voices", info=_wav_dir_info, preview=_wav_dir_preview, - start=wav_start if wav_start is not None else Path.cwd(), + start=wav_start if wav_start is not None else VOICES_DIR, back_value=_GO_BACK) if wav_dir is _GO_BACK: step = 3 @@ -1540,8 +1550,8 @@ def _collect_from_flags(args: argparse.Namespace, and config.AUDIOCPP_CLONE_MODEL_ID == entry_ids[0]): sync_model_ids = not args.no_sync_model_ids - # Wav dir + transcription plan. - wav_dir = args.input_dir + # Wav dir + transcription plan (defaults to the project's voices/ dir). + wav_dir = args.input_dir if args.input_dir is not None else VOICES_DIR plan: Optional[dict] = None if include_clone and wav_dir is not None: wav_files = find_wav_files(wav_dir) @@ -1582,8 +1592,9 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--wavs", type=resolve_wav_dir_arg, default=None, dest="input_dir", metavar="WAV_DIR", help="Directory with .wav reference files to publish as " - "a server-level voice_dir cloning library (asked " - "for when omitted in the TUI)") + "a server-level voice_dir cloning library " + f"(default: {VOICES_DIR}; asked for when omitted " + "in the TUI)") parser.add_argument("--output", type=Path, default=None, help="Output path for server.json (default: " "server.json inside the audio.cpp checkout; an " @@ -1664,17 +1675,24 @@ def detect() -> BackendStatus: details.append("not built — run setup to build audiocpp_server") server_json = checkout / "server.json" configured = server_json.exists() + servers: List[ServerSpec] = [] if configured: details.append(f"config: {server_json}") - launch = (f"{binary} --config {server_json}" - if built else - f"./build/--release/bin/" - f"audiocpp_server --config {server_json}") + if built: + servers = [ServerSpec( + "audiocpp", config.AUDIOCPP_API_URL, + [str(binary), "--config", str(server_json)])] + else: + launch = (f"./build/--release/bin/" + f"audiocpp_server --config {server_json}") else: details.append("no server.json — run setup to configure models") + if servers: + launch = format_launch_hint(servers) return BackendStatus("audiocpp", "audio.cpp", installed=built, configured=configured, running=running, - details=details, launch_hint=launch) + details=details, launch_hint=launch, + servers=servers) configure_actions: List[ConfigureAction] = [ diff --git a/backends/common.py b/backends/common.py index d707fdc..2529a8f 100644 --- a/backends/common.py +++ b/backends/common.py @@ -20,6 +20,11 @@ from typing import Dict, List, Optional, Set, Tuple # (./audio.cpp, ./faster-qwen3-tts) so a single tree holds everything. TTS_ROOT = Path(__file__).resolve().parent.parent +# The project's sample-voice directory: .wav files dropped here are offered +# as the default source when a setup/configure wizard asks for a wav +# directory (both the TUI browser start and the --wavs flag default). +VOICES_DIR = TTS_ROOT / "voices" + # converter/config.py — rewritten in place by update_config_value so the # converter picks up the host/port/voice a wizard configured. CONFIG_PATH = TTS_ROOT / "converter" / "config.py" @@ -249,8 +254,12 @@ def git_clone(url: str, target: Path) -> int: def pip_install(packages: List[str]) -> int: - """pip install PACKAGES (into the current environment). Returns exit code.""" - print(f"[INFO] pip install {' '.join(packages)}...") - import sys - return run_console_subprocess([sys.executable, "-m", "pip", "install", - *packages]) + """pip install PACKAGES into the managed venv (``envs/tts``). Returns exit code. + + Delegates to ``backends.envs.pip_install`` so backend TTS packages are + installed alongside the app requirements in the tool-managed environment + rather than into whatever interpreter happens to be running the wizard. + The import is local to avoid a circular import (envs imports this module). + """ + from backends import envs + return envs.pip_install(packages) diff --git a/backends/envs.py b/backends/envs.py new file mode 100644 index 0000000..7596db6 --- /dev/null +++ b/backends/envs.py @@ -0,0 +1,185 @@ +"""The managed Python environment for the audiobook generator and its backends. + +audiobook.py is meant to be launched from any Python (a bare system interpreter +is fine): on startup it bootstraps a single tool-managed venv at +``envs/tts`` and re-execs itself inside it. That venv holds both the +audiobook app's own ``requirements.txt`` dependencies and the backend TTS +packages (``qwen-tts``, ``faster-qwen3-tts[demo]``) the setup wizards pip +install, so nothing is ever installed into the launching interpreter's +environment. + +A parent process never needs to "activate" an environment — activation is +just a shell convenience that puts an env's ``bin`` on PATH. Instead every +helper here resolves the env's binaries by absolute path +(``envs/tts/bin/python``, ``envs/tts/bin/qwen-tts-demo``), so the hub can +spawn servers in this env from any parent environment. + +This module is imported before audiobook.py's third-party dependencies, so +it must stay stdlib-only (it may import ``backends.common``, which is also +stdlib-only, but never ``converter`` or the backend modules). +""" + +import hashlib +import os +import sys +from pathlib import Path +from typing import List + +from backends import common + +# The tts-audiobook-generator checkout root (where audiobook.py lives). +TTS_ROOT = Path(__file__).resolve().parent.parent + +# One shared venv for the app requirements and every pip-installed backend. +ENV_DIR = TTS_ROOT / "envs" / "tts" +REQUIREMENTS_PATH = TTS_ROOT / "requirements.txt" + +# Marker file recording the requirements.txt hash last installed into the env, +# so ensure_app_env() re-installs when requirements.txt changes. +MARKER_PATH = ENV_DIR / ".audiobook_env_ready" + + +def _is_windows() -> bool: + return sys.platform == "win32" + + +def env_python() -> Path: + """Absolute path to the venv's python interpreter.""" + return ENV_DIR / ("Scripts/python.exe" if _is_windows() else "bin/python") + + +def env_script(name: str) -> Path: + """Absolute path to a console script installed in the venv (e.g. qwen-tts-demo).""" + subdir = "Scripts" if _is_windows() else "bin" + suffix = ".exe" if _is_windows() else "" + return ENV_DIR / subdir / f"{name}{suffix}" + + +def env_exists() -> bool: + """True when the venv's python interpreter is present on disk.""" + return env_python().is_file() + + +def is_managed_env() -> bool: + """True when the current process is already running inside the managed venv.""" + try: + return Path(sys.executable).resolve() == env_python().resolve() + except OSError: + return False + + +def create_env() -> int: + """Create the venv with the launching interpreter (inherits its version). + + pip is bootstrapped inside the venv by ensurepip. Returns the ``python -m + venv`` exit code; a non-zero result is reported with platform remediation. + """ + print(f"[INFO] creating managed environment at {ENV_DIR}...") + rc = common.run_console_subprocess( + [sys.executable, "-m", "venv", str(ENV_DIR)]) + if rc != 0: + print(f"[ERROR] python -m venv failed (exit {rc}).") + if _is_windows(): + print(" On Windows make sure the launcher has the venv module.") + else: + print(" On Debian/Ubuntu install the venv package, e.g.:") + print(" sudo apt install python3-venv") + return rc + + +def install_requirements() -> int: + """pip install -r requirements.txt into the venv. Returns pip's exit code.""" + print(f"[INFO] pip install -r {REQUIREMENTS_PATH} into {ENV_DIR}...") + return common.run_console_subprocess( + [str(env_python()), "-m", "pip", "install", "-r", str(REQUIREMENTS_PATH)]) + + +def pip_install(packages: List[str]) -> int: + """pip install PACKAGES into the venv, creating it first if needed. + + Used by the qwen/faster setup wizards to install backend TTS packages + alongside the app requirements. Returns pip's exit code. + """ + if not env_exists() and create_env() != 0: + return 1 + print(f"[INFO] pip install {' '.join(packages)} into {ENV_DIR}...") + return common.run_console_subprocess( + [str(env_python()), "-m", "pip", "install", *packages]) + + +def module_available(module: str) -> bool: + """True when MODULE imports inside the venv (e.g. qwen_tts, faster_qwen3_tts). + + A short subprocess probe against the venv's interpreter — the equivalent of + importlib.util.find_spec, but for the managed env rather than the current + one. Used by each backend's ``_is_installed``. + """ + if not env_exists(): + return False + import subprocess + try: + result = subprocess.run( + [str(env_python()), "-c", f"import {module}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=30, check=False) + except (OSError, subprocess.TimeoutExpired): + return False + return result.returncode == 0 + + +def _requirements_sha() -> str: + try: + data = REQUIREMENTS_PATH.read_bytes() + except OSError: + return "" + return hashlib.sha256(data).hexdigest() + + +def _marker_valid() -> bool: + try: + return MARKER_PATH.read_text(encoding="utf-8").strip() == _requirements_sha() + except OSError: + return False + + +def _write_marker() -> None: + try: + MARKER_PATH.write_text(_requirements_sha() + "\n", encoding="utf-8") + except OSError: + pass + + +def ensure_app_env() -> None: + """Make sure the venv exists and has the current requirements.txt installed. + + Creates the venv when missing, and (re)installs requirements.txt when it is + missing or has changed since the last install (tracked by a hash marker). + Raises RuntimeError on any failure so the caller can abort before re-exec. + """ + if not env_exists() and create_env() != 0: + raise RuntimeError("could not create the managed environment") + if not _marker_valid(): + if install_requirements() != 0: + raise RuntimeError("pip install -r requirements.txt failed") + _write_marker() + + +def bootstrap(script_path: str) -> None: + """Run audiobook.py inside the managed venv, creating it first if needed. + + A no-op when the current process is already the venv's interpreter. Otherwise + ensures the env (and requirements) are ready, then replaces the process with + the venv's python running the same script and CLI args. Called at the top of + audiobook.py before any third-party import. + """ + if is_managed_env(): + return + try: + ensure_app_env() + except RuntimeError as exc: + print(f"[FATAL] {exc}", file=sys.stderr) + sys.exit(1) + py = str(env_python()) + target = str(Path(script_path).resolve()) + print(f"[INFO] re-launching inside managed environment: {py}") + os.execv(py, [py, target, *sys.argv[1:]]) diff --git a/backends/faster.py b/backends/faster.py index 71be050..50c6102 100755 --- a/backends/faster.py +++ b/backends/faster.py @@ -17,7 +17,6 @@ Usage: """ import argparse -import importlib.util import json import sys from pathlib import Path @@ -25,13 +24,27 @@ from typing import List, Optional sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from ui import tui -from backends import BackendStatus, ConfigureAction -from backends import common -from backends.common import TTS_ROOT, find_wav_files, normalize_dir_arg +from backends import ( + BackendStatus, + ConfigureAction, + ServerSpec, + common, + envs, + format_launch_hint, +) +from backends.common import ( + TTS_ROOT, + VOICES_DIR, + find_wav_files, + normalize_dir_arg, +) from converter import config -from converter.tts import normalize_language, transcribe_reference_audio, \ - whisper_backend_available +from converter.tts import ( + normalize_language, + transcribe_reference_audio, + whisper_backend_available, +) +from ui import tui FASTER_DIR_NAME = "faster-qwen3-tts" FASTER_GIT_URL = "https://github.com/andimarafioti/faster-qwen3-tts" @@ -44,7 +57,7 @@ def _checkout() -> Path: def _is_installed() -> bool: - return importlib.util.find_spec("faster_qwen3_tts") is not None + return envs.module_available("faster_qwen3_tts") def _is_cloned() -> bool: @@ -133,7 +146,7 @@ def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]: wav_dir = tui.browse_directory( stdscr, "Select the directory with your .wav voices", info=common.wav_dir_info, preview=common.wav_dir_preview, - start=Path.cwd()) + start=VOICES_DIR) language = args.language if language is None: lang_text = tui.line_edit( @@ -239,8 +252,9 @@ def _execute(settings: dict) -> int: def _print_launch_hint(voices_path: Path, port: int) -> None: print() if _is_cloned(): - print("Start the server with:") - print(f" python {_checkout()}/examples/openai_server.py " + py = envs.env_python() + print("Start the server with (or use the hub's 'Server' menu):") + print(f" {py} {_checkout()}/examples/openai_server.py " f"--voices {voices_path} --port {port}") else: print("[INFO] Clone faster-qwen3-tts to get examples/openai_server.py,") @@ -270,25 +284,23 @@ def run_tui(args: Optional[argparse.Namespace] = None) -> int: def _collect_from_flags(args: argparse.Namespace, parser: argparse.ArgumentParser) -> Optional[dict]: """Build the settings dict from flags for a non-interactive run.""" - if args.input_dir is None: - parser.error("--wavs is required in a non-interactive run (or run " - "without flags for the TUI wizard)") - if not args.input_dir.is_dir(): - parser.error(f"WAV directory not found: {args.input_dir}") + wav_dir = args.input_dir if args.input_dir is not None else VOICES_DIR + if not wav_dir.is_dir(): + parser.error(f"WAV directory not found: {wav_dir}") try: language = normalize_language(args.language or config.LANGUAGE) except ValueError as exc: parser.error(str(exc)) output_path = args.output if args.output is not None \ else ((_checkout() / "voices.json") if _is_cloned() - else (args.input_dir / "voices.json")) + else (wav_dir / "voices.json")) if output_path.exists() and not args.force: print("[INFO] Aborted; existing voices.json kept") return None return { "do_install": (not _is_installed()) and not args.skip_install, "do_clone": (not _is_cloned()) and not args.skip_clone, - "wav_dir": args.input_dir, + "wav_dir": wav_dir, "language": language, "whisper_model": args.whisper_model or "base", "output_path": output_path, @@ -303,8 +315,8 @@ def build_parser() -> argparse.ArgumentParser: "build voices.json, and sync converter/config.py.") parser.add_argument("input_dir", type=normalize_dir_arg, nargs="?", default=None, metavar="WAV_DIR", - help="Directory with .wav reference files (required in " - "a non-interactive run; browsed for in the TUI)") + help="Directory with .wav reference files " + f"(default: {VOICES_DIR}; browsed for in the TUI)") parser.add_argument("--output", type=Path, default=None, help="Output path for voices.json (default: " "./faster-qwen3-tts/voices.json, or " @@ -344,13 +356,18 @@ def detect() -> BackendStatus: details.append(f"voices: {voices_json}" if voices_json.exists() else "no voices.json — run setup to create one") launch = "" + servers: List[ServerSpec] = [] if cloned and voices_json.exists(): - launch = (f"python {_checkout()}/examples/openai_server.py " - f"--voices {voices_json} --port {_config_port()}") + argv = [str(envs.env_python()), + str(_checkout() / "examples" / "openai_server.py"), + "--voices", str(voices_json), "--port", str(_config_port())] + servers = [ServerSpec("faster", config.FASTER_API_URL, argv)] + launch = format_launch_hint(servers) return BackendStatus("faster", "faster-qwen3-tts", installed=installed and cloned, configured=configured, running=running, - details=details, launch_hint=launch) + details=details, launch_hint=launch, + servers=servers) def _run_voices_only_tui() -> int: diff --git a/backends/qwen.py b/backends/qwen.py index 60f3bb6..52f7a3f 100644 --- a/backends/qwen.py +++ b/backends/qwen.py @@ -14,18 +14,22 @@ Usage: """ import argparse -import importlib.util -import shutil import sys from pathlib import Path from typing import List, Optional sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from ui import tui -from backends import BackendStatus, ConfigureAction -from backends import common +from backends import ( + BackendStatus, + ConfigureAction, + ServerSpec, + common, + envs, + format_launch_hint, +) from converter import config +from ui import tui QWEN_PIP_PKG = "qwen-tts" QWEN_CUSTOMVOICE_MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice" @@ -39,9 +43,9 @@ QWEN_SPEAKERS = ("Vivian", "Serena", "Uncle_Fu", "Dylan", "Eric", "Ryan", def _is_installed() -> bool: - if shutil.which("qwen-tts-demo"): + if envs.env_script("qwen-tts-demo").is_file(): return True - return importlib.util.find_spec("qwen_tts") is not None + return envs.module_available("qwen_tts") def _config_port(url: str, fallback: int) -> int: @@ -144,11 +148,13 @@ def _execute(settings: dict) -> int: def _print_launch_hint(custom_port: int, clone_port: int) -> None: + demo = envs.env_script("qwen-tts-demo") print() - print("Start the servers (in separate terminals):") - print(f" qwen-tts-demo {QWEN_CUSTOMVOICE_MODEL} --ip 127.0.0.1 " + print("Start the servers (in separate terminals), or use the hub's") + print("'Server' menu / let a conversion start one automatically:") + print(f" {demo} {QWEN_CUSTOMVOICE_MODEL} --ip 127.0.0.1 " f"--port {custom_port}") - print(f" qwen-tts-demo {QWEN_BASE_MODEL} --ip 127.0.0.1 " + print(f" {demo} {QWEN_BASE_MODEL} --ip 127.0.0.1 " f"--port {clone_port}") print("Then run: python audiobook.py --backend qwen") @@ -219,13 +225,20 @@ def detect() -> BackendStatus: details.append(f"CustomVoice port: {custom_port}") details.append(f"Base (clone) port: {clone_port}") details.append(f"speaker: {config.SPEAKER}") - launch = (f"qwen-tts-demo {QWEN_CUSTOMVOICE_MODEL} --ip 127.0.0.1 " - f"--port {custom_port} ; qwen-tts-demo {QWEN_BASE_MODEL} " - f"--ip 127.0.0.1 --port {clone_port}") + demo = str(envs.env_script("qwen-tts-demo")) + servers = [ + ServerSpec("qwen-custom", config.QWEN_API_URL, + [demo, QWEN_CUSTOMVOICE_MODEL, "--ip", "127.0.0.1", + "--port", str(custom_port)]), + ServerSpec("qwen-clone", config.CLONE_API_URL, + [demo, QWEN_BASE_MODEL, "--ip", "127.0.0.1", + "--port", str(clone_port)]), + ] return BackendStatus("qwen", "qwen-tts", installed=installed, configured=installed, running=running, details=details, - launch_hint=launch) + launch_hint=format_launch_hint(servers), + servers=servers) configure_actions: List[ConfigureAction] = [ diff --git a/backends/servers.py b/backends/servers.py new file mode 100644 index 0000000..12846f0 --- /dev/null +++ b/backends/servers.py @@ -0,0 +1,244 @@ +"""Start and stop TTS backend servers from the TUI hub. + +Each backend's ``detect()`` returns a list of ``ServerSpec`` — the exact argv +(absolute binaries in the managed venv, no shell activation needed) and the +URL to probe for readiness. This module turns those specs into running +processes: ``start`` spawns the server, streams its output to +``logs/-server.log``, records its pid, and polls the URL until it +accepts connections (model loads are slow, so the timeout is generous); +``stop`` terminates the process group the hub started. + +Everything here runs in the plain console tail after the curses TUI returns +(matching the wizards' build/pip streaming), so progress and log tails appear +normally. Pid/log files live under ``logs/`` which is already gitignored. +""" + +import os +import signal +import subprocess +import sys +import time +from pathlib import Path +from typing import List + +from backends import common +from backends.common import TTS_ROOT + +LOG_DIR = TTS_ROOT / "logs" + +# How long to wait for a server to accept connections on its URL. First-time +# model loads (especially qwen-tts / faster-qwen3-tts pulling weights into +# VRAM) can take minutes, so this is deliberately generous. +SERVER_START_TIMEOUT = 600 + +# Grace period after SIGTERM before escalating to SIGKILL (POSIX). +STOP_GRACE_SECONDS = 10 + + +def _log_path(name: str) -> Path: + return LOG_DIR / f"{name}-server.log" + + +def _pid_path(name: str) -> Path: + return LOG_DIR / f"{name}-server.pid" + + +def _tail_log(name: str, lines: int = 20) -> None: + """Print the last LINES of the server's log (best-effort).""" + path = _log_path(name) + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return + tail = "\n".join(text.splitlines()[-lines:]) + if tail: + print(f"--- last {lines} lines of {path} ---") + print(tail) + print("---") + + +def _pid_alive(pid: int) -> bool: + """True when a process with PID is still running (POSIX signal-0 probe).""" + if sys.platform == "win32": + try: + import ctypes + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + handle = kernel32.OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not handle: + return False + kernel32.CloseHandle(handle) + return True + except OSError: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _kill_pid(pid: int) -> bool: + """Terminate PID (and its process group on POSIX). Returns True when dead.""" + if sys.platform == "win32": + try: + os.kill(pid, signal.SIGTERM) + except (ProcessLookupError, PermissionError, OSError): + return not _pid_alive(pid) + for _ in range(int(STOP_GRACE_SECONDS * 10)): + if not _pid_alive(pid): + return True + time.sleep(0.1) + try: + os.kill(pid, signal.SIGTERM) + except OSError: + pass + return not _pid_alive(pid) + # POSIX: kill the whole process group (started with start_new_session=True). + try: + pgid = os.getpgid(pid) + except ProcessLookupError: + return True + try: + os.killpg(pgid, signal.SIGTERM) + except ProcessLookupError: + return True + except PermissionError: + return False + for _ in range(int(STOP_GRACE_SECONDS * 10)): + try: + os.killpg(pgid, 0) + except ProcessLookupError: + return True + except PermissionError: + return False + time.sleep(0.1) + try: + os.killpg(pgid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + return True + + +def start(spec) -> bool: + """Start the server described by SPEC (a ``backends.ServerSpec``). + + Spawns its argv with stdout/stderr to ``logs/-server.log``, records + the pid, and polls ``common.server_running(spec.url)`` until it accepts + connections or ``SERVER_START_TIMEOUT`` elapses. Returns True when the + server is up; on timeout or early exit, prints the log tail and returns + False. A no-op (True) when the server is already running. + """ + argv: List[str] = list(spec.argv) + exe = Path(argv[0]) + if not exe.exists(): + print(f"[ERROR] server executable not found: {exe}") + print(" run 'Set up a backend' for " + f"{spec.name!r} first.") + return False + if common.server_running(spec.url): + print(f"[INFO] {spec.name} server already running on {spec.url}") + return True + + LOG_DIR.mkdir(parents=True, exist_ok=True) + pid_file = _pid_path(spec.name) + if pid_file.exists(): + try: + pid_file.unlink() + except OSError: + pass + + print(f"[INFO] starting {spec.name} server: " + + " ".join(str(a) for a in argv)) + log_handle = _log_path(spec.name).open("w", encoding="utf-8") + popen_kwargs = {"stdout": log_handle, "stderr": subprocess.STDOUT} + if sys.platform == "win32": + popen_kwargs["creationflags"] = \ + subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + else: + popen_kwargs["start_new_session"] = True + try: + proc = subprocess.Popen(argv, **popen_kwargs) + except OSError as exc: + print(f"[ERROR] could not start server: {exc}") + log_handle.close() + return False + + pid_file.write_text(str(proc.pid), encoding="utf-8") + print(f"[INFO] pid {proc.pid}; logs: {_log_path(spec.name)}") + + deadline = time.time() + SERVER_START_TIMEOUT + while time.time() < deadline: + if proc.poll() is not None: + print(f"[ERROR] {spec.name} server exited with code " + f"{proc.returncode}") + _tail_log(spec.name) + try: + pid_file.unlink() + except OSError: + pass + return False + if common.server_running(spec.url): + print(f"[OK] {spec.name} server is up on {spec.url}") + return True + time.sleep(1) + print(f"[ERROR] {spec.name} server did not start within " + f"{SERVER_START_TIMEOUT}s") + _tail_log(spec.name) + # Leave the pid file in place so stop() can kill it (it may still load). + return False + + +def stop(name: str) -> bool: + """Stop a server previously started by ``start`` (identified by pid file). + + Returns True when the process was terminated (or already gone). Returns + False when there is no pid file — the server was not started by this tool, + so the user must stop it manually (e.g. close its terminal). + """ + pid_file = _pid_path(name) + if not pid_file.exists(): + print(f"[INFO] no pid file for '{name}' " + "(not started by this tool — stop it manually)") + return False + try: + pid = int(pid_file.read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + print(f"[WARNING] could not read pid file {pid_file}; removing it") + try: + pid_file.unlink() + except OSError: + pass + return False + if not _pid_alive(pid): + print(f"[INFO] {name} server (pid {pid}) already stopped") + try: + pid_file.unlink() + except OSError: + pass + return True + print(f"[INFO] stopping {name} server (pid {pid})...") + killed = _kill_pid(pid) + if killed: + print(f"[OK] {name} server stopped") + else: + print(f"[WARNING] could not stop pid {pid}; stop it manually") + try: + pid_file.unlink() + except OSError: + pass + return killed + + +def pid_for(name: str): + """Return the recorded pid for NAME, or None when no pid file exists.""" + pid_file = _pid_path(name) + if not pid_file.exists(): + return None + try: + return int(pid_file.read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + return None -- cgit v1.2.3