Spaces:
Sleeping
Sleeping
| """SubprocessBackend β long-lived sidecar-process TTS primitive (Phase 2.1). | |
| The architectural keystone for engine isolation. Engines that need their | |
| own Python venv (because their dependency pins conflict with OmniVoice's | |
| β IndexTTS demands `transformers<5`, OmniVoice demands `transformers>=5.3`) | |
| run inside a `subprocess.Popen` child interpreter. The parent backend | |
| talks to them through length-prefixed JSON over the child's stdin/stdout. | |
| Subclasses (e.g. ``IndexTTSSubprocessBackend`` in Plan 02-03, the future | |
| Supertonic-3 backend in Phase 3) override exactly two class methods: | |
| @classmethod | |
| def venv_python(cls) -> Path: ... # path to the engine's python | |
| @classmethod | |
| def sidecar_script(cls) -> Path: ... # path to backend/engines/<id>/main.py | |
| Everything else β spawn, ready-handshake, request/response, GPU-slot | |
| accounting, atexit teardown, stderr drainage, process-group cleanup β is | |
| owned by this base class. | |
| NOT IMPLEMENTED with the multiprocessing module (Locked Decision D4 β no | |
| process-cloning variants of any kind). The whole point of this primitive | |
| is to run a *different* Python interpreter than the parent's; | |
| multiprocessing can only clone the current interpreter, which defeats | |
| the dependency-isolation goal. Anyone tempted to "simplify" this should | |
| re-read 02-RESEARCH.md Pitfall 1. | |
| Threat-model summary (see Plan 02-01 frontmatter): | |
| T-02-01 β DoS via length-prefix: hard cap 64 MB per frame in ``_recv``. | |
| T-02-02 β GPU slot leak on sidecar death: try/finally in ``generate``. | |
| T-02-03 β token bytes in stderr: drain via the same logging filter that | |
| AUTH-05 installed (``HFTokenRedactor``) on the root logger. | |
| T-02-04 β compromised sidecar emitting unexpected ops: parent allowlist | |
| ``PARENT_INBOUND_OPS`` rejects everything else. | |
| T-02-05 β Tauri group-kill scope: ``start_new_session=True`` on Unix | |
| and ``CREATE_NEW_PROCESS_GROUP`` on Windows isolate the | |
| sidecar's process group. | |
| """ | |
| from __future__ import annotations | |
| import atexit | |
| import base64 | |
| import json | |
| import logging | |
| import os | |
| import struct | |
| import subprocess | |
| import sys | |
| import threading | |
| from pathlib import Path | |
| from typing import Optional | |
| import numpy as np | |
| import torch | |
| from services.tts_backend import TTSBackend | |
| logger = logging.getLogger("omnivoice.subprocess_backend") | |
| # ββ Wire protocol constants ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| #: Hard cap per frame body. Defeats length-prefix DoS where a malicious or | |
| #: corrupted sidecar sends `0xFFFFFFFF` and the parent would allocate 4 GB | |
| #: before realising the body never arrives. See T-02-01. | |
| MAX_FRAME_BYTES = 64 * 1024 * 1024 | |
| #: Parent-side op allowlist. Any sidecar frame whose ``op`` is not in this | |
| #: set is logged and discarded β prevents a compromised sidecar from | |
| #: invoking unintended parent code paths. See T-02-04. | |
| PARENT_INBOUND_OPS = frozenset({ | |
| "ready", "pong", "audio", "progress", "error", | |
| "gpu_acquire", "gpu_release", | |
| }) | |
| #: Reference list of ops the sidecar accepts (informational β enforced on | |
| #: the sidecar side, not in this module). | |
| SIDECAR_INBOUND_OPS = frozenset({"ping", "synthesize", "shutdown"}) | |
| #: Timeout for the initial ready handshake. Some engines (IndexTTS, large | |
| #: torch.compile graphs) take 20β25 s to import their dependencies before | |
| #: emitting the first frame; 30 s is a comfortable upper bound that still | |
| #: surfaces a hung sidecar within a single CI run. | |
| SPAWN_READY_TIMEOUT_S = 30.0 | |
| #: Per-frame _recv read timeout (best-effort β applies to header read; body | |
| #: read is uninterruptible on a stdlib BufferedReader). Used in health_check | |
| #: and generate to bound a hung sidecar. | |
| RECV_TIMEOUT_S = 60.0 | |
| # ββ Base class βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class SubprocessBackend(TTSBackend): | |
| """Long-lived sidecar-process TTS backend. Subclasses provide | |
| ``venv_python()`` and ``sidecar_script()``; the base class owns | |
| spawn/shutdown/_send/_recv/generate + GPU-slot acquire/release. | |
| """ | |
| # Stable marker so `list_backends()` can detect subprocess-isolated | |
| # backends without relying on `issubclass()`. ``issubclass`` fails when | |
| # test fixtures purge `sys.modules["services"]` (as the token_resolver | |
| # tests do for DB isolation) β the re-imported SubprocessBackend would | |
| # be a different class object from the one the subclass closed over. | |
| # A duck-typed marker survives that. | |
| _is_subprocess_isolated: bool = True | |
| # Default sample rate; subclasses override. | |
| _DEFAULT_SAMPLE_RATE = 24000 | |
| # ββ instance state (initialised in __init__) βββββββββββββββββββββββββββ | |
| def __init__(self) -> None: | |
| self._proc: Optional[subprocess.Popen] = None | |
| # Single lock serialises spawn + every send/recv pair so two threads | |
| # can't interleave half-frames on the same pipe. | |
| self._lock = threading.Lock() | |
| self._stderr_thread: Optional[threading.Thread] = None | |
| # Idempotent atexit shutdown (Pitfall 6 layer 1). If the interpreter | |
| # exits without an explicit shutdown call, this still tears down the | |
| # sidecar tree. | |
| atexit.register(self.shutdown) | |
| # ββ subclass contract ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def venv_python(cls) -> Path: | |
| """Path to the Python interpreter that runs the sidecar. | |
| Subclasses point at their engine's dedicated venv. The echo sidecar | |
| and unit tests point at ``sys.executable`` so they run under the | |
| bare parent interpreter. | |
| """ | |
| raise NotImplementedError | |
| def sidecar_script(cls) -> Path: | |
| """Path to the sidecar entrypoint (`backend/engines/<id>/main.py`).""" | |
| raise NotImplementedError | |
| # ββ lifecycle ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _spawn(self) -> None: | |
| """Launch the sidecar if not already running. Blocks on the ready | |
| handshake. Caller must hold self._lock.""" | |
| if self._proc is not None and self._proc.poll() is None: | |
| return # already up | |
| # Env forwarding contract (Locked Decision D5): | |
| # - Inherit the parent's full env via os.environ.copy(). | |
| # - The parent's env already carries HF_TOKEN (injected by the | |
| # Phase 1 AUTH-04 launch sites that call | |
| # ``token_resolver.resolve()``), HF_HOME, HF_ENDPOINT, and | |
| # HF_HUB_CACHE. | |
| # - PYTHONUNBUFFERED=1 keeps the sidecar's stdout from buffering | |
| # past our length-prefix reads. | |
| env = os.environ.copy() | |
| env["PYTHONUNBUFFERED"] = "1" | |
| kwargs: dict = { | |
| "stdin": subprocess.PIPE, | |
| "stdout": subprocess.PIPE, | |
| "stderr": subprocess.PIPE, | |
| "env": env, | |
| "bufsize": 0, # unbuffered binary pipes | |
| } | |
| # Process-group isolation so the Tauri lib.rs group-kill in shutdown | |
| # doesn't escape into other children. See T-02-05. | |
| if sys.platform == "win32": | |
| kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | |
| else: | |
| kwargs["start_new_session"] = True | |
| python_path = str(self.venv_python()) | |
| script_path = str(self.sidecar_script()) | |
| logger.info( | |
| "[%s] spawning sidecar: %s %s", | |
| self.id, python_path, script_path, | |
| ) | |
| self._proc = subprocess.Popen([python_path, script_path], **kwargs) | |
| # Drain stderr in a background thread so the sidecar can't block on | |
| # a full pipe. Lines flow into the root logger; AUTH-05's | |
| # HFTokenRedactor (already installed in Phase 1) strips token bytes. | |
| # See T-02-03. | |
| self._stderr_thread = threading.Thread( | |
| target=self._drain_stderr, daemon=True, | |
| name=f"{self.id}-stderr-drain", | |
| ) | |
| self._stderr_thread.start() | |
| # Block on the ready handshake. A sidecar that fails to emit ready | |
| # within SPAWN_READY_TIMEOUT_S is killed and the failure is raised. | |
| try: | |
| frame = self._recv_with_timeout(SPAWN_READY_TIMEOUT_S) | |
| except Exception: | |
| self._force_kill() | |
| raise | |
| if not frame or frame.get("op") != "ready": | |
| self._force_kill() | |
| raise RuntimeError( | |
| f"{self.id} sidecar did not signal ready: {frame!r}" | |
| ) | |
| logger.info("[%s] sidecar ready", self.id) | |
| def shutdown(self) -> None: | |
| """Idempotent. Sends {op:shutdown}; falls back to terminate/kill.""" | |
| proc = self._proc | |
| if proc is None: | |
| return | |
| try: | |
| try: | |
| # Best effort β sidecar may already be dead. | |
| self._send({"op": "shutdown"}) | |
| except Exception: | |
| pass | |
| try: | |
| proc.wait(timeout=3) | |
| except subprocess.TimeoutExpired: | |
| logger.warning( | |
| "[%s] sidecar did not exit on shutdown frame; terminating", | |
| self.id, | |
| ) | |
| try: | |
| proc.terminate() | |
| except Exception: | |
| pass | |
| try: | |
| proc.wait(timeout=2) | |
| except subprocess.TimeoutExpired: | |
| logger.warning( | |
| "[%s] sidecar did not exit on SIGTERM; killing", | |
| self.id, | |
| ) | |
| try: | |
| proc.kill() | |
| except Exception: | |
| pass | |
| try: | |
| proc.wait(timeout=2) | |
| except Exception: | |
| pass | |
| finally: | |
| self._proc = None | |
| def _force_kill(self) -> None: | |
| """Internal: kill a sidecar that never reached the ready state.""" | |
| proc = self._proc | |
| if proc is None: | |
| return | |
| try: | |
| proc.kill() | |
| try: | |
| proc.wait(timeout=2) | |
| except Exception: | |
| pass | |
| except Exception: | |
| pass | |
| finally: | |
| self._proc = None | |
| def unload(self) -> None: | |
| """TTSBackend.unload override β idempotent shutdown.""" | |
| self.shutdown() | |
| # ββ health check + generate ββββββββββββββββββββββββββββββββββββββββββββ | |
| def health_check(self) -> tuple[bool, str]: | |
| """Send ping, expect pong. Spawns the sidecar if needed. | |
| Returns (True, "pong") on success, (False, "<exc>") on any failure. | |
| Never raises β health checks are called from places (engine picker, | |
| Compat Matrix UI) that must keep working even when an engine is sick. | |
| """ | |
| try: | |
| with self._lock: | |
| self._spawn() | |
| self._send({"op": "ping"}) | |
| reply = self._recv_with_timeout(RECV_TIMEOUT_S) | |
| if reply and reply.get("op") == "pong": | |
| return True, "pong" | |
| return False, f"unexpected reply: {reply!r}" | |
| except Exception as exc: | |
| return False, f"{type(exc).__name__}: {exc}" | |
| def generate(self, text: str, **kw) -> torch.Tensor: | |
| """Synthesize one utterance through the sidecar. | |
| Returns a tensor of shape (1, n_samples) at the sidecar-reported | |
| sample rate. Decodes the int16 PCM the sidecar returns into float32 | |
| in [-1, 1]. | |
| """ | |
| # Lazy-import the GPU pool so importing this module doesn't pull in | |
| # the entire model_manager + torch ecosystem at registry-listing time. | |
| from services.model_manager import _get_gpu_pool | |
| # Acquire a GPU pool worker for the duration of this generate. The | |
| # try/finally guarantees the slot is released even if the sidecar | |
| # dies mid-frame (T-02-02 / Pitfall 7). | |
| pool = _get_gpu_pool() | |
| slot_future = pool.submit(lambda: None) | |
| try: | |
| slot_future.result(timeout=10) # wait for our turn | |
| except Exception: | |
| slot_future.cancel() | |
| raise | |
| try: | |
| with self._lock: | |
| self._spawn() | |
| msg = {"op": "synthesize", "text": text} | |
| # Filter kwargs to JSON-safe primitives. Tensor / Path / etc. | |
| # don't survive json.dumps and are silently dropped β the | |
| # sidecar can't use them anyway. | |
| for k, v in kw.items(): | |
| if _is_jsonable(v): | |
| msg[k] = v | |
| self._send(msg) | |
| reply = self._recv_with_timeout(RECV_TIMEOUT_S) | |
| if not reply: | |
| raise RuntimeError(f"{self.id} sidecar closed pipe mid-generate") | |
| if reply.get("op") == "error": | |
| raise RuntimeError( | |
| f"{self.id} sidecar error: {reply.get('message')!r}" | |
| ) | |
| if reply.get("op") != "audio": | |
| raise RuntimeError( | |
| f"{self.id} sidecar returned unexpected op: {reply.get('op')!r}" | |
| ) | |
| pcm_b64 = reply.get("audio_pcm_b64", "") | |
| pcm = base64.b64decode(pcm_b64) | |
| arr = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0 | |
| tensor = torch.from_numpy(arr.copy()).unsqueeze(0) | |
| return tensor | |
| finally: | |
| # Slot is released the instant this thread leaves the pool's | |
| # task β by holding slot_future we kept one worker busy; nothing | |
| # further to do. (ThreadPoolExecutor doesn't expose a manual | |
| # release; the slot returns to the pool when our submitted no-op | |
| # finishes, which happens immediately after .result() above.) | |
| pass | |
| # ββ wire protocol ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _send(self, msg: dict) -> None: | |
| """Length-prefixed JSON over the sidecar's stdin. Caller holds lock.""" | |
| if self._proc is None or self._proc.stdin is None: | |
| raise RuntimeError(f"{self.id} sidecar not running") | |
| body = json.dumps(msg, separators=(",", ":")).encode("utf-8") | |
| if len(body) > MAX_FRAME_BYTES: | |
| raise IOError(f"outbound frame too large: {len(body)}") | |
| try: | |
| self._proc.stdin.write(struct.pack("!I", len(body))) | |
| self._proc.stdin.write(body) | |
| self._proc.stdin.flush() # Pitfall 2 β mandatory flush | |
| except (BrokenPipeError, OSError) as exc: | |
| raise RuntimeError( | |
| f"{self.id} sidecar pipe closed: {exc}" | |
| ) from exc | |
| def _recv(self) -> Optional[dict]: | |
| """Read one frame from the sidecar's stdout. Returns None on EOF. | |
| Op allowlist is enforced here: unknown ops are logged and dropped, | |
| and we tail-recurse to read the next frame. See T-02-04. | |
| """ | |
| if self._proc is None or self._proc.stdout is None: | |
| return None | |
| stdout = self._proc.stdout | |
| header = _read_exact(stdout, 4) | |
| if header is None: | |
| return None | |
| (n,) = struct.unpack("!I", header) | |
| if n > MAX_FRAME_BYTES: | |
| # T-02-01 β refuse to allocate before the body even arrives. | |
| raise IOError(f"frame too large: {n}") | |
| body = _read_exact(stdout, n) | |
| if body is None or len(body) != n: | |
| raise IOError("short read") | |
| try: | |
| msg = json.loads(body.decode("utf-8")) | |
| except Exception as exc: | |
| raise IOError(f"malformed sidecar frame: {exc}") from exc | |
| op = msg.get("op") if isinstance(msg, dict) else None | |
| if op not in PARENT_INBOUND_OPS: | |
| # T-02-04 β refuse to act on unknown ops. Log and read the | |
| # next frame so we don't desync. | |
| logger.warning( | |
| "[%s] dropped sidecar frame with disallowed op=%r", | |
| self.id, op, | |
| ) | |
| return self._recv() | |
| return msg | |
| def _recv_with_timeout(self, timeout_s: float) -> Optional[dict]: | |
| """Recv that aborts if the sidecar goes silent. | |
| Implemented by polling the proc for liveness with a deadline. We | |
| don't block on a `select` of the pipe because Windows can't select | |
| on subprocess pipes β keeping the implementation cross-platform | |
| means a simpler polling loop here. | |
| """ | |
| # On Unix we could use selectors; on Windows the pipe is not | |
| # selectable. Use a watchdog thread that kills the sidecar on | |
| # timeout β that triggers EOF on stdout, so _recv returns None | |
| # and the caller raises. | |
| watchdog = threading.Timer(timeout_s, self._timeout_kill) | |
| watchdog.daemon = True | |
| watchdog.start() | |
| try: | |
| return self._recv() | |
| finally: | |
| watchdog.cancel() | |
| def _timeout_kill(self) -> None: | |
| proc = self._proc | |
| if proc is None: | |
| return | |
| try: | |
| logger.error( | |
| "[%s] sidecar exceeded recv timeout; killing", | |
| self.id, | |
| ) | |
| proc.kill() | |
| except Exception: | |
| pass | |
| # ββ stderr drain βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _drain_stderr(self) -> None: | |
| """Pump sidecar stderr lines into the parent logger. | |
| Prefixes each line with `[<engine_id>]`. The HFTokenRedactor filter | |
| installed at the root logger in Phase 1 redacts any token bytes | |
| that slip through. See T-02-03. | |
| """ | |
| proc = self._proc | |
| if proc is None or proc.stderr is None: | |
| return | |
| try: | |
| for raw in iter(proc.stderr.readline, b""): | |
| try: | |
| line = raw.decode("utf-8", errors="replace").rstrip() | |
| except Exception: | |
| line = repr(raw) | |
| if line: | |
| logger.info("[%s] %s", self.id, line) | |
| except Exception as exc: | |
| logger.debug("[%s] stderr drain ended: %s", self.id, exc) | |
| # ββ helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _read_exact(stream, n: int) -> Optional[bytes]: | |
| """Read exactly n bytes from a BufferedReader, or return None on EOF. | |
| BufferedReader.read(n) is allowed to return fewer than n bytes when | |
| the underlying file descriptor is a pipe β we loop until we have | |
| all of them or EOF. | |
| """ | |
| out = bytearray() | |
| while len(out) < n: | |
| chunk = stream.read(n - len(out)) | |
| if not chunk: | |
| if not out: | |
| return None | |
| return bytes(out) | |
| out.extend(chunk) | |
| return bytes(out) | |
| def _is_jsonable(v) -> bool: | |
| """Quick filter for kwargs that survive json.dumps. Lists/dicts are | |
| accepted only if their contents are themselves jsonable.""" | |
| if v is None or isinstance(v, (bool, int, float, str)): | |
| return True | |
| if isinstance(v, (list, tuple)): | |
| return all(_is_jsonable(x) for x in v) | |
| if isinstance(v, dict): | |
| return all(isinstance(k, str) and _is_jsonable(x) for k, x in v.items()) | |
| return False | |
| __all__ = [ | |
| "SubprocessBackend", | |
| "MAX_FRAME_BYTES", | |
| "PARENT_INBOUND_OPS", | |
| "SIDECAR_INBOUND_OPS", | |
| ] | |