Spaces:
Sleeping
Sleeping
| """Tests for backend/services/subprocess_backend.py β Plan 02-01 Task 1. | |
| Covers the SubprocessBackend round-trip via the permanent echo sidecar at | |
| backend/engines/_echo/main.py. | |
| These tests intentionally spawn a real subprocess (the echo sidecar uses | |
| the parent's `sys.executable` so no engine venv is required). They run on | |
| macOS, Linux, and Windows. | |
| """ | |
| from __future__ import annotations | |
| import io | |
| import os | |
| import struct | |
| import sys | |
| import threading | |
| import time | |
| from pathlib import Path | |
| from typing import Optional | |
| import psutil | |
| import pytest | |
| import torch | |
| # tests/conftest.py prepends ./backend to sys.path so `services.*` resolves. | |
| from services.subprocess_backend import ( | |
| MAX_FRAME_BYTES, | |
| PARENT_INBOUND_OPS, | |
| SubprocessBackend, | |
| _read_exact, | |
| ) | |
| REPO_ROOT = Path(__file__).resolve().parents[3] | |
| ECHO_SCRIPT = REPO_ROOT / "backend" / "engines" / "_echo" / "main.py" | |
| # ββ test-only subclass βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class EchoBackend(SubprocessBackend): | |
| """In-test subclass β runs the echo sidecar under `sys.executable`.""" | |
| id = "_echo" | |
| display_name = "Echo (test)" | |
| def sample_rate(self) -> int: | |
| return 24000 | |
| def supported_languages(self) -> list[str]: | |
| return ["multi"] | |
| def is_available(cls) -> tuple[bool, str]: | |
| if ECHO_SCRIPT.is_file(): | |
| return True, "ready" | |
| return False, f"echo sidecar missing at {ECHO_SCRIPT}" | |
| def venv_python(cls) -> Path: | |
| return Path(sys.executable) | |
| def sidecar_script(cls) -> Path: | |
| return ECHO_SCRIPT | |
| # ββ fixtures βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def echo_backend(): | |
| backend = EchoBackend() | |
| yield backend | |
| try: | |
| backend.shutdown() | |
| except Exception: | |
| pass | |
| # ββ round-trip tests βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_echo_script_exists(): | |
| """Permanent CI regression infrastructure assertion.""" | |
| assert ECHO_SCRIPT.is_file(), ( | |
| f"Echo sidecar missing at {ECHO_SCRIPT}. This file is permanent " | |
| "CI regression infrastructure for SubprocessBackend β do not delete." | |
| ) | |
| def test_echo_round_trip(echo_backend): | |
| """Spawn β synthesize("hello") β expect 1 s of int16 silence as float32.""" | |
| audio = echo_backend.generate("hello") | |
| assert isinstance(audio, torch.Tensor) | |
| assert audio.shape == (1, 24000), f"got shape {tuple(audio.shape)}" | |
| assert audio.dtype == torch.float32 | |
| # Silence β exact zeros after int16 β float32 division. | |
| assert torch.max(torch.abs(audio)).item() == 0.0 | |
| def test_health_check_pings(echo_backend): | |
| ok, msg = echo_backend.health_check() | |
| assert ok, f"health_check failed: {msg}" | |
| assert msg == "pong" | |
| def test_no_zombie_after_shutdown(echo_backend): | |
| """Spawn β record PID β shutdown β assert PID is gone within 3 s.""" | |
| # Trigger spawn via a ping. | |
| ok, _ = echo_backend.health_check() | |
| assert ok | |
| assert echo_backend._proc is not None | |
| pid = echo_backend._proc.pid | |
| echo_backend.shutdown() | |
| # Poll for up to 3 s; the sidecar should be reaped by wait()/kill(). | |
| deadline = time.monotonic() + 3.0 | |
| while time.monotonic() < deadline: | |
| if not psutil.pid_exists(pid): | |
| break | |
| # Or: zombie state is OK (parent reaped it via wait()). | |
| try: | |
| p = psutil.Process(pid) | |
| if p.status() == psutil.STATUS_ZOMBIE: | |
| # Reap it ourselves for the assertion below. | |
| p.wait(timeout=0.5) | |
| break | |
| except psutil.NoSuchProcess: | |
| break | |
| time.sleep(0.1) | |
| assert not psutil.pid_exists(pid) or _is_zombie_or_dead(pid), ( | |
| f"sidecar PID {pid} still alive after shutdown" | |
| ) | |
| def _is_zombie_or_dead(pid: int) -> bool: | |
| try: | |
| p = psutil.Process(pid) | |
| return p.status() in (psutil.STATUS_ZOMBIE, psutil.STATUS_DEAD) | |
| except psutil.NoSuchProcess: | |
| return True | |
| def test_shutdown_idempotent(echo_backend): | |
| """Calling shutdown twice must not raise.""" | |
| echo_backend.health_check() | |
| echo_backend.shutdown() | |
| echo_backend.shutdown() # second call is a no-op | |
| def test_env_forwarding_contract(monkeypatch, tmp_path): | |
| """HF_TOKEN, HF_HOME, HF_ENDPOINT, HF_HUB_CACHE all reach the sidecar. | |
| Locked Decision D5 β verifies the os.environ.copy() contract that Phase | |
| 2 Wave 2 (IndexTTS migration) relies on for cache discovery. | |
| """ | |
| monkeypatch.setenv("HF_TOKEN", "hf_test_abc") | |
| monkeypatch.setenv("HF_HOME", str(tmp_path / "hf_home")) | |
| monkeypatch.setenv("HF_ENDPOINT", "https://hf-mirror.com") | |
| monkeypatch.setenv("HF_HUB_CACHE", str(tmp_path / "hf_cache")) | |
| monkeypatch.setenv("OMNIVOICE_ECHO_TEST_MODE", "1") | |
| backend = EchoBackend() | |
| try: | |
| # Use the raw send/recv path to invoke the test-only probe_env op. | |
| with backend._lock: | |
| backend._spawn() | |
| backend._send({"op": "probe_env"}) | |
| # probe_env_result isn't in the public PARENT_INBOUND_OPS allowlist | |
| # by design (it's test-only), so we read it directly via the wire | |
| # to avoid the allowlist drop. Reach into stdout for one frame. | |
| proc = backend._proc | |
| assert proc is not None | |
| header = _read_exact(proc.stdout, 4) | |
| assert header is not None | |
| (n,) = struct.unpack("!I", header) | |
| body = _read_exact(proc.stdout, n) | |
| assert body is not None | |
| import json as _json | |
| reply = _json.loads(body.decode("utf-8")) | |
| assert reply["op"] == "probe_env_result" | |
| keys = reply["keys"] | |
| assert keys["HF_TOKEN"] == "hf_test_abc" | |
| assert keys["HF_HOME"] == str(tmp_path / "hf_home") | |
| assert keys["HF_ENDPOINT"] == "https://hf-mirror.com" | |
| assert keys["HF_HUB_CACHE"] == str(tmp_path / "hf_cache") | |
| finally: | |
| backend.shutdown() | |
| # ββ frame protocol DoS guards ββββββββββββββββββββββββββββββββββββββββββββββ | |
| class _FakeStdout: | |
| """Mock for stdin/stdout pipe in _recv unit tests.""" | |
| def __init__(self, data: bytes): | |
| self._buf = io.BytesIO(data) | |
| def read(self, n: int) -> bytes: | |
| return self._buf.read(n) | |
| class _MockProc: | |
| def __init__(self, stdout_data: bytes): | |
| self.stdout = _FakeStdout(stdout_data) | |
| self.stdin = None | |
| def poll(self) -> Optional[int]: | |
| return 0 # claim "already exited" so shutdown is a no-op | |
| def wait(self, timeout: float | None = None) -> int: | |
| return 0 | |
| def terminate(self) -> None: | |
| pass | |
| def kill(self) -> None: | |
| pass | |
| def test_oversize_frame_rejected(): | |
| """A length-prefix > MAX_FRAME_BYTES must raise IOError before allocation. | |
| T-02-01 β defends against malicious sidecar sending 0xFFFFFFFF to make | |
| the parent allocate 4 GB. | |
| """ | |
| backend = EchoBackend() | |
| try: | |
| # Inject a fake proc whose stdout starts with a 100 MB length prefix. | |
| huge = MAX_FRAME_BYTES + 1 | |
| backend._proc = _MockProc(struct.pack("!I", huge)) # type: ignore[assignment] | |
| with pytest.raises(IOError, match="frame too large"): | |
| backend._recv() | |
| finally: | |
| # Drop the mock so atexit shutdown doesn't try to wait on it. | |
| backend._proc = None | |
| def test_short_read_rejected(): | |
| """Length prefix says 100 bytes; only 50 arrive before EOF β IOError.""" | |
| backend = EchoBackend() | |
| try: | |
| bad = struct.pack("!I", 100) + b"\x00" * 50 # only 50 of 100 bytes | |
| backend._proc = _MockProc(bad) # type: ignore[assignment] | |
| with pytest.raises(IOError, match="short read"): | |
| backend._recv() | |
| finally: | |
| backend._proc = None | |
| def test_op_allowlist_drops_unknown(monkeypatch): | |
| """An unknown sidecar op is logged and discarded; parent keeps reading. | |
| T-02-04 β set up the echo sidecar to emit an `exfiltrate` op followed | |
| by a legitimate `pong`; parent must silently drop the first and return | |
| the second. | |
| """ | |
| monkeypatch.setenv("OMNIVOICE_ECHO_TEST_MODE", "1") | |
| backend = EchoBackend() | |
| try: | |
| with backend._lock: | |
| backend._spawn() | |
| backend._send({"op": "emit_unknown"}) | |
| # The sidecar emits {exfiltrate}, then {pong}. _recv must skip | |
| # the first frame and return the pong. | |
| reply = backend._recv_with_timeout(5.0) | |
| assert reply is not None | |
| assert reply["op"] == "pong" | |
| finally: | |
| backend.shutdown() | |
| def test_op_allowlist_constant_shape(): | |
| """PARENT_INBOUND_OPS must contain exactly the documented set.""" | |
| assert PARENT_INBOUND_OPS == frozenset({ | |
| "ready", "pong", "audio", "progress", "error", | |
| "gpu_acquire", "gpu_release", | |
| }) | |
| # Sentinel β common typo / accidental addition would fail this test. | |
| assert "exfiltrate" not in PARENT_INBOUND_OPS | |
| assert "synthesize" not in PARENT_INBOUND_OPS # that's a sidecar-inbound op | |
| # ββ crash-recovery / GPU slot accounting βββββββββββββββββββββββββββββββββββ | |
| def test_sidecar_crash_releases_resources(monkeypatch, echo_backend): | |
| """A sidecar that os._exit(1)'s mid-frame must not leave the parent | |
| holding state β `_proc` becomes detectable as dead and a fresh spawn | |
| works.""" | |
| monkeypatch.setenv("OMNIVOICE_ECHO_CRASH", "1") | |
| monkeypatch.setenv("OMNIVOICE_ECHO_TEST_MODE", "1") | |
| # First generate causes the sidecar to crash after sending the audio | |
| # frame (the crash hook fires after frames_handled >= 1). | |
| audio = echo_backend.generate("hello") | |
| assert audio.shape == (1, 24000) | |
| # Give the crashed child a moment to actually exit. | |
| time.sleep(0.5) | |
| # The next generate must spawn a fresh sidecar β the broken pipe from | |
| # the dead child should be detected, shutdown clears _proc, and a new | |
| # spawn succeeds. We allow the generate to fail OR to succeed after | |
| # respawn; the invariant is "the backend doesn't deadlock or wedge". | |
| try: | |
| echo_backend.shutdown() | |
| except Exception: | |
| pass | |
| # Disable the crash hook so the next spawn lives. | |
| monkeypatch.delenv("OMNIVOICE_ECHO_CRASH", raising=False) | |
| # Fresh spawn works. | |
| ok, msg = echo_backend.health_check() | |
| assert ok, f"recovery failed: {msg}" | |
| # ββ module-level helpers βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def test_no_multiprocessing_imports(): | |
| """Locked decision D4 β no `mp.Process`/`mp.fork`/`mp.spawn` allowed.""" | |
| src = (REPO_ROOT / "backend" / "services" / "subprocess_backend.py").read_text() | |
| for bad in ("mp.Process", "mp.fork", "mp.spawn", | |
| "multiprocessing.Process", "multiprocessing.fork", | |
| "multiprocessing.spawn"): | |
| assert bad not in src, ( | |
| f"Found forbidden multiprocessing pattern {bad!r} in " | |
| "subprocess_backend.py β see locked decision D4 / Pitfall 1." | |
| ) | |
| def test_max_frame_bytes_is_64mb(): | |
| assert MAX_FRAME_BYTES == 64 * 1024 * 1024 | |