aboutsummaryrefslogtreecommitdiff
path: root/app/backends/common.py
diff options
context:
space:
mode:
Diffstat (limited to 'app/backends/common.py')
-rw-r--r--app/backends/common.py182
1 files changed, 170 insertions, 12 deletions
diff --git a/app/backends/common.py b/app/backends/common.py
index 08c8863..cb573c3 100644
--- a/app/backends/common.py
+++ b/app/backends/common.py
@@ -11,10 +11,18 @@ run.
import os
import re
+import sys
+import time
import urllib.parse
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
+# Messages queued while the TUI is on screen, printed to the real console
+# after the curses session ends (see ui.hub.run). Build/setup steps that
+# fail inside the TUI record here so the user gets a copy-pastable command
+# and a log path once the TUI exits, instead of losing the output.
+_POST_TUI_NOTICES: List[str] = []
+
# The tts-audiobook-generator checkout root (where audiobook.py lives).
# Everything non-user-facing lives under ./app: the source packages
# (backends, converter, ui), the generated dirs (envs, chunks, logs, debug),
@@ -24,6 +32,9 @@ TTS_ROOT = Path(__file__).resolve().parent.parent.parent
# The single "everything else" directory under TTS_ROOT.
APP_DIR = TTS_ROOT / "app"
+# app/logs — build/server/conversion logs (already gitignored).
+LOG_DIR = APP_DIR / "logs"
+
# 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).
@@ -42,6 +53,24 @@ TTS_OUTPUT_DIR = "output"
PROMPT_TEXT_FILENAME = "prompt_text"
+def record_post_tui_notice(text: str) -> None:
+ """Queue a message to print to the console after the TUI session ends.
+
+ The TUI runs in a curses session, so ``print`` during it does not reach
+ the real terminal. Steps that fail inside the TUI (e.g. the audio.cpp
+ build) record a copy-pastable command and a log path here; ``ui.hub.run``
+ drains the queue after the session ends.
+ """
+ _POST_TUI_NOTICES.append(text)
+
+
+def drain_post_tui_notices() -> List[str]:
+ """Return and clear the queued post-TUI messages."""
+ notices = list(_POST_TUI_NOTICES)
+ _POST_TUI_NOTICES.clear()
+ return notices
+
+
def normalize_dir_arg(value: str) -> Path:
"""Normalize a user-supplied path argument.
@@ -267,26 +296,155 @@ def write_prompt_text(wav_dir: Path,
return prompt_path
-def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None) -> int:
- """Run a subprocess whose output streams to the plain console.
+def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None,
+ *, emit=None, cancel=None, on_cancel=None) -> int:
+ """Run a subprocess, streaming output to the console or to EMIT.
+
+ With EMIT None the child inherits the real terminal and its output
+ appears normally (used by the non-interactive CLI paths and the quick
+ ``tui.suspend`` actions like uninstall). With EMIT given (a
+ ``callable(str)``) the child's stdout/stderr are merged, read line by
+ line (splitting on both ``\\n`` and ``\\r`` so carriage-return progress
+ updates like git's or tqdm's surface as lines), and each line is passed
+ to EMIT — the in-TUI task view path.
- Used inside ``tui.suspend`` for clone/build/pip steps: the caller has
- already left curses mode, so the child inherits the real terminal and
- its output appears normally. Returns the process exit code.
+ CANCEL is an optional ``threading.Event``: once set, ON_CANCEL (if given)
+ is called (e.g. to touch a ``--cancel-file``), then the child's process
+ group is terminated (SIGTERM, escalating to SIGKILL after a grace
+ period) and 130 is returned. Returns the process exit code.
"""
import subprocess
+ if emit is None:
+ try:
+ result = subprocess.run(argv,
+ cwd=str(cwd) if cwd is not None else None)
+ except OSError as exc:
+ print(f"[ERROR] Could not run {' '.join(argv)}: {exc}")
+ return 1
+ return result.returncode
+
+ popen_kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.STDOUT}
+ if cwd is not None:
+ popen_kwargs["cwd"] = str(cwd)
+ if sys.platform == "win32":
+ popen_kwargs["creationflags"] = \
+ subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
+ else:
+ popen_kwargs["start_new_session"] = True
try:
- result = subprocess.run(argv, cwd=str(cwd) if cwd is not None else None)
+ proc = subprocess.Popen(argv, **popen_kwargs)
except OSError as exc:
- print(f"[ERROR] Could not run {' '.join(argv)}: {exc}")
+ emit(f"[ERROR] Could not run {' '.join(argv)}: {exc}")
return 1
- return result.returncode
+ cancelled = False
+
+ def _reader() -> None:
+ try:
+ for raw in iter(proc.stdout.readline, b""):
+ if not raw:
+ break
+ text = raw.decode("utf-8", errors="replace")
+ for line in text.splitlines():
+ if line:
+ emit(line)
+ except (OSError, ValueError):
+ pass
+
+ reader = _spawn_reader(_reader)
+ while True:
+ if cancel is not None and cancel.is_set():
+ cancelled = True
+ if on_cancel is not None:
+ try:
+ on_cancel()
+ except Exception:
+ pass
+ # Give a graceful-cancel hook (e.g. a --cancel-file) a
+ # moment to let the child exit cleanly before forcing it.
+ grace_end = time.time() + 3
+ while time.time() < grace_end:
+ if proc.poll() is not None:
+ break
+ time.sleep(0.1)
+ if proc.poll() is None:
+ _terminate_process_group(proc)
+ break
+ if proc.poll() is not None:
+ break
+ time.sleep(0.1)
+ try:
+ reader.join(timeout=5)
+ finally:
+ if reader.is_alive():
+ reader.join(timeout=0)
+ if cancelled:
+ return 130
+ return proc.returncode
+
+
+def _spawn_reader(target):
+ import threading
+ thread = threading.Thread(target=target, daemon=True)
+ thread.start()
+ return thread
+
+
+def _terminate_process_group(proc) -> None:
+ """Terminate PROC's process group (SIGTERM, then SIGKILL after a grace).
-def git_clone(url: str, target: Path) -> int:
- """Clone URL into TARGET, streaming to the console. Returns exit code."""
- print(f"[INFO] Cloning {url} into {target}...")
- return run_console_subprocess(["git", "clone", url, str(target)])
+ Death is detected with ``proc.poll()`` (which reaps the zombie) rather
+ than a ``killpg(pgid, 0)`` probe — the latter still succeeds on a
+ zombie, so it would always wait the full grace period.
+ """
+ import signal
+ if sys.platform == "win32":
+ try:
+ proc.terminate()
+ except OSError:
+ pass
+ deadline = time.time() + 10
+ while time.time() < deadline:
+ if proc.poll() is not None:
+ return
+ time.sleep(0.1)
+ try:
+ proc.kill()
+ except OSError:
+ pass
+ return
+ try:
+ pgid = os.getpgid(proc.pid)
+ except (ProcessLookupError, OSError):
+ return
+ try:
+ os.killpg(pgid, signal.SIGTERM)
+ except (ProcessLookupError, OSError):
+ return
+ deadline = time.time() + 10
+ while time.time() < deadline:
+ if proc.poll() is not None:
+ return
+ time.sleep(0.1)
+ try:
+ os.killpg(pgid, signal.SIGKILL)
+ except (ProcessLookupError, OSError):
+ pass
+ proc.wait()
+
+
+def git_clone(url: str, target: Path, *, emit=None, cancel=None) -> int:
+ """Clone URL into TARGET, streaming to the console or to EMIT. Returns
+ the exit code."""
+ if emit is None:
+ print(f"[INFO] Cloning {url} into {target}...")
+ return run_console_subprocess(["git", "clone", url, str(target)])
+ emit(f"[INFO] Cloning {url} into {target}...")
+ # --progress makes git report percentage updates even though stderr is
+ # piped (it normally only does so on a terminal), feeding the task view.
+ return run_console_subprocess(
+ ["git", "clone", "--progress", url, str(target)],
+ emit=emit, cancel=cancel)
def pip_install(packages: List[str]) -> int: