* 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>
159 lines
5.5 KiB
Python
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())
|