Files
VoiceStudio/backend/engines/_echo/main.py
T
Palash DebnathandClaude Opus 4.7 0fc5ea6cf3 Phase 2 Plan 02-01: SubprocessBackend primitive (Wave 1 of Phase 2) (#97)
* Phase 2 Plan 02-01: SubprocessBackend primitive + echo sidecar + ENGINE-05 wrap

Lands the durable SubprocessBackend primitive — the architectural keystone
that Plans 02-03 (IndexTTS migration), Phase 3 (Supertonic-3), and
Phase 4 (GGUF / Singing) plug into.

Files added:
  - backend/services/subprocess_backend.py — base class owning spawn,
    shutdown, _send/_recv (length-prefixed JSON), GPU-slot acquire-release,
    atexit teardown, stderr drain, op allowlist (T-02-04), and 64 MB
    frame cap (T-02-01). No multiprocessing — subprocess.Popen
    exclusively so subclasses can target a *different* venv's interpreter
    (Locked Decision D4 / Pitfall 1).
  - backend/engines/_echo/main.py — permanent CI regression sidecar.
    Stdlib-only, runs under the parent's sys.executable. Implements
    ready/ping-pong/synthesize/shutdown plus test-only probe_env and
    emit_unknown ops for env-forwarding and op-allowlist tests. DO NOT
    DELETE — the round-trip test depends on this file.
  - tests/backend/services/test_subprocess_backend.py — 13 tests:
    round-trip, health_check, no-zombie, shutdown idempotency, env
    forwarding (HF_TOKEN/HF_HOME/HF_ENDPOINT/HF_HUB_CACHE), oversize
    frame, short read, op-allowlist drop, op-allowlist constant shape,
    sidecar-crash recovery, no-multiprocessing grep gate, MAX_FRAME_BYTES.
  - tests/backend/services/test_tts_backend_registry.py — 6 tests for
    list_backends() resilience + shape + isolation_mode + last_error
    caching + existing-engines preservation + install_hint passthrough.

Files modified:
  - backend/services/tts_backend.py:
    * Adds module-level _LAST_ERRORS dict for ENGINE-06.
    * Rewrites list_backends() to wrap each is_available() in try/except
      so one broken engine cannot blank the picker (ENGINE-05).
    * Adds last_error + isolation_mode keys to each response entry
      (ENGINE-06 UI in Plan 02-04 consumes via the same /engines route).
    * Uses a duck-typed _is_subprocess_isolated marker rather than
      issubclass(cls, SubprocessBackend) because test fixtures (token
      resolver suite) purge sys.modules["services"] between tests and the
      re-imported SubprocessBackend would be a different class object.

Threat-model mitigations (Plan 02-01 frontmatter):
  T-02-01 DoS via length-prefix → MAX_FRAME_BYTES = 64 * 1024 * 1024
  T-02-02 GPU slot leak on sidecar death → try/finally in generate
  T-02-03 token bytes in stderr → drained via parent logger
          (HFTokenRedactor from Phase 1 already on root)
  T-02-04 unknown ops from compromised sidecar → PARENT_INBOUND_OPS
          allowlist, unknown frames logged and dropped
  T-02-05 Tauri group-kill scope → start_new_session=True on Unix /
          CREATE_NEW_PROCESS_GROUP on Windows

Verification:
  - 337 passed, 6 skipped, 12 xfailed, 1 xpassed (full suite,
    `uv run pytest tests/ --ignore=tests/manual`)
  - All 19 new tests pass on macOS Apple Silicon
  - Smoke tests still pass: `uv run pytest tests/smoke/ -q` → 4 passed
  - SoniTranslate untouched (D1 locked decision)
  - Zero new Python dependencies

Closes part of ENGINE-01 + ENGINE-05.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(02-01): plan summary — public API, invariants, deviations

Documents the SubprocessBackend public API so Plan 02-03 (IndexTTS) and
Phase 3 (Supertonic-3) authors don't need to re-read the source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 06:54:24 +05:30

159 lines
5.5 KiB
Python

"""Permanent CI regression-test echo sidecar — DO NOT delete.
Keeps `backend/services/subprocess_backend.py::SubprocessBackend` round-trip
working as a green build gate. This file is the contract that proves the
length-prefixed JSON wire protocol still works even when no production
engine (IndexTTS, Supertonic-3, etc.) is installed.
Wire protocol (length-prefixed JSON over stdin/stdout, identical to
SubprocessBackend._send/_recv):
[ 4-byte big-endian uint32 length ][ N bytes UTF-8 JSON ]
Op flow:
1. on start: sidecar emits {"op":"ready","engine":"_echo"}
2. parent → {"op":"ping"} → sidecar replies {"op":"pong"}
3. parent → {"op":"synthesize","text":"...","sample_rate":24000} →
sidecar replies {"op":"audio","audio_pcm_b64":<base64 1 s int16
zeros>,"sample_rate":24000,"n_samples":24000}
4. parent → {"op":"shutdown"} → sidecar returns 0
5. unknown op → sidecar emits {"op":"error","stage":"dispatch",
"message":"unknown op: ..."} and continues
Test-only ops (only registered when OMNIVOICE_ECHO_TEST_MODE=1):
- {"op":"probe_env"} → echo back the four canonical HF env vars
Test-only crash hook (only when OMNIVOICE_ECHO_CRASH=1): the sidecar will
self-`os._exit(1)` after dispatching exactly one frame, to exercise the
parent's "sidecar died mid-generate" recovery path.
This script is stdlib-only on purpose — no torch, no numpy. The whole point
of the echo sidecar is that it can spawn under the bare system Python
interpreter without any engine venv.
"""
from __future__ import annotations
import base64
import json
import os
import struct
import sys
import traceback
# Mirrors backend/services/subprocess_backend.py::MAX_FRAME_BYTES.
MAX_FRAME_BYTES = 64 * 1024 * 1024
def _send(stream, obj: dict) -> None:
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
stream.write(struct.pack("!I", len(body)))
stream.write(body)
stream.flush()
def _recv(stream):
header = stream.read(4)
if len(header) < 4:
return None # EOF
(n,) = struct.unpack("!I", header)
if n > MAX_FRAME_BYTES:
raise IOError(f"frame too large: {n}")
body = bytearray()
while len(body) < n:
chunk = stream.read(n - len(body))
if not chunk:
raise IOError("short read")
body.extend(chunk)
return json.loads(bytes(body).decode("utf-8"))
def _silence_pcm_b64(sample_rate: int) -> tuple[str, int]:
"""Return base64-encoded 1 second of int16 silence at the given rate."""
n_samples = int(sample_rate)
# int16 silence = two zero bytes per sample. No numpy dependency.
pcm = b"\x00\x00" * n_samples
return base64.b64encode(pcm).decode("ascii"), n_samples
def main() -> int:
stdin = sys.stdin.buffer
stdout = sys.stdout.buffer
test_mode = os.environ.get("OMNIVOICE_ECHO_TEST_MODE") == "1"
crash_after_one = os.environ.get("OMNIVOICE_ECHO_CRASH") == "1"
_send(stdout, {"op": "ready", "engine": "_echo"})
frames_handled = 0
while True:
try:
msg = _recv(stdin)
except Exception as exc:
_send(stdout, {
"op": "error",
"stage": "recv",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
return 1
if msg is None:
return 0
op = msg.get("op")
try:
if op == "ping":
_send(stdout, {"op": "pong"})
elif op == "synthesize":
sr = int(msg.get("sample_rate", 24000) or 24000)
pcm_b64, n_samples = _silence_pcm_b64(sr)
_send(stdout, {
"op": "audio",
"audio_pcm_b64": pcm_b64,
"sample_rate": sr,
"n_samples": n_samples,
})
elif op == "shutdown":
return 0
elif op == "probe_env" and test_mode:
_send(stdout, {
"op": "probe_env_result",
"keys": {
"HF_TOKEN": os.environ.get("HF_TOKEN"),
"HF_HOME": os.environ.get("HF_HOME"),
"HF_ENDPOINT": os.environ.get("HF_ENDPOINT"),
"HF_HUB_CACHE": os.environ.get("HF_HUB_CACHE"),
},
})
elif op == "emit_unknown" and test_mode:
# Test hook for T-02-04: sidecar emits an op the parent's
# allowlist must drop without crashing.
_send(stdout, {"op": "exfiltrate", "payload": "ignored"})
# Immediately follow with a valid pong so the parent test can
# see that subsequent frames still flow after the rejection.
_send(stdout, {"op": "pong"})
else:
_send(stdout, {
"op": "error",
"stage": "dispatch",
"message": f"unknown op: {op!r}",
})
except Exception as exc:
_send(stdout, {
"op": "error",
"stage": "handler",
"message": f"{type(exc).__name__}: {exc}",
"traceback": traceback.format_exc(),
})
return 1
frames_handled += 1
if crash_after_one and frames_handled >= 1:
# Hard exit — mimics a sidecar segfault mid-session so the parent
# finally-clause has to release the GPU slot.
os._exit(1)
if __name__ == "__main__":
sys.exit(main())