OmniVoice-Studio / tests /backend /services /test_subprocess_backend.py
LΓͺ Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
11.7 kB
"""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)"
@property
def sample_rate(self) -> int:
return 24000
@property
def supported_languages(self) -> list[str]:
return ["multi"]
@classmethod
def is_available(cls) -> tuple[bool, str]:
if ECHO_SCRIPT.is_file():
return True, "ready"
return False, f"echo sidecar missing at {ECHO_SCRIPT}"
@classmethod
def venv_python(cls) -> Path:
return Path(sys.executable)
@classmethod
def sidecar_script(cls) -> Path:
return ECHO_SCRIPT
# ── fixtures ───────────────────────────────────────────────────────────────
@pytest.fixture
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