fix(sidecars): reconcile receive deadlines with outer job budgets

This commit is contained in:
Palash Debnath
2026-09-17 12:27:56 +05:30
13 changed files with 324 additions and 18 deletions
+4 -1
View File
@@ -16,6 +16,10 @@ the frozen-backend fallback mirror it for their toolchains.
- Handle missing Electron signing credentials and retry packaging fixes without moving release tags (#2157) - Handle missing Electron signing credentials and retry packaging fixes without moving release tags (#2157)
### Fixed
- Give isolated engines request-sized deadlines, validate timeout overrides, and distinguish hangs from crashes (#2109) (#2111) — thanks @SurefireStudios and @LMGXENON!
## [0.5.3] — 2026-09-17 ## [0.5.3] — 2026-09-17
**Highlights** **Highlights**
@@ -79,7 +83,6 @@ the frozen-backend fallback mirror it for their toolchains.
- "Disable torch.compile" in Settings → Performance now works on macOS and Linux, not only Windows; it was greyed out on the platforms that needed it (#2135) - "Disable torch.compile" in Settings → Performance now works on macOS and Linux, not only Windows; it was greyed out on the platforms that needed it (#2135)
- Setting `TORCH_COMPILE_DISABLE=1` in the environment now actually disables torch.compile, for the in-process engine and engine subprocesses alike (#2135) - Setting `TORCH_COMPILE_DISABLE=1` in the environment now actually disables torch.compile, for the in-process engine and engine subprocesses alike (#2135)
- A backend killed by a native crash now leaves the faulting thread's stack in `backend_err.log` instead of exiting silently (#2135) - A backend killed by a native crash now leaves the faulting thread's stack in `backend_err.log` instead of exiting silently (#2135)
- Confucius4-TTS, dots.tts, MOSS-TTS-v1.5 and Supertonic-3 are no longer killed at 60 seconds mid-sentence: a sidecar that sets no deadline of its own now gets one that outlasts its job's own time budget, and a sidecar stopped by that deadline says so instead of reporting a closed pipe (#2109, #2103) — thanks @martinezpl!
### CI ### CI
+7
View File
@@ -807,6 +807,7 @@ def _oom_friendly_reraise(e):
def _generate_timeout_s( def _generate_timeout_s(
text: str, text: str,
*, *,
engine: object = None,
execution_device=None, execution_device=None,
min_vram_gb=0.0, min_vram_gb=0.0,
hardware_family=None, hardware_family=None,
@@ -827,6 +828,7 @@ def _generate_timeout_s(
from services.model_manager import generate_timeout_s from services.model_manager import generate_timeout_s
return generate_timeout_s( return generate_timeout_s(
text, text,
engine=engine,
execution_device=execution_device, execution_device=execution_device,
min_vram_gb=min_vram_gb, min_vram_gb=min_vram_gb,
hardware_family=hardware_family, hardware_family=hardware_family,
@@ -1752,6 +1754,7 @@ async def generate_speech(
what="TTS generate", what="TTS generate",
timeout=_generate_timeout_s( timeout=_generate_timeout_s(
text, text,
engine=_backend,
execution_device=_routing["effective_device"], execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb, min_vram_gb=_engine_min_vram_gb,
hardware_family=_routing_hardware_family, hardware_family=_routing_hardware_family,
@@ -2052,6 +2055,7 @@ async def generate_speech(
min_vram_gb=_engine_min_vram_gb, min_vram_gb=_engine_min_vram_gb,
timeout=_generate_timeout_s( timeout=_generate_timeout_s(
text, text,
engine=_backend,
execution_device=_routing["effective_device"], execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb, min_vram_gb=_engine_min_vram_gb,
hardware_family=_routing_hardware_family, hardware_family=_routing_hardware_family,
@@ -2078,6 +2082,7 @@ async def generate_speech(
min_vram_gb=_engine_min_vram_gb, min_vram_gb=_engine_min_vram_gb,
timeout=_generate_timeout_s( timeout=_generate_timeout_s(
text, text,
engine=_backend,
execution_device=_routing["effective_device"], execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb, min_vram_gb=_engine_min_vram_gb,
hardware_family=_routing_hardware_family, hardware_family=_routing_hardware_family,
@@ -2124,6 +2129,7 @@ async def generate_speech(
# even after the v0.3.22 scaled budget shipped. # even after the v0.3.22 scaled budget shipped.
timeout=_generate_timeout_s( timeout=_generate_timeout_s(
chunk_text, chunk_text,
engine=_backend,
execution_device=_routing["effective_device"], execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb, min_vram_gb=_engine_min_vram_gb,
hardware_family=_routing_hardware_family, hardware_family=_routing_hardware_family,
@@ -2290,6 +2296,7 @@ async def generate_speech(
_local_render, what="TTS generate", _local_render, what="TTS generate",
timeout=_generate_timeout_s( timeout=_generate_timeout_s(
text, text,
engine=_backend,
execution_device=_routing["effective_device"], execution_device=_routing["effective_device"],
min_vram_gb=_engine_min_vram_gb, min_vram_gb=_engine_min_vram_gb,
hardware_family=_routing_hardware_family, hardware_family=_routing_hardware_family,
+1
View File
@@ -337,6 +337,7 @@ async def convert_speech(
what="Voice convert", what="Voice convert",
timeout=_generate_timeout_s( timeout=_generate_timeout_s(
text, text,
engine=backend,
execution_device=compute_profile["effective_device"], execution_device=compute_profile["effective_device"],
min_vram_gb=compute_profile["min_vram_gb"], min_vram_gb=compute_profile["min_vram_gb"],
hardware_family=compute_profile.get("runtime_hardware_family"), hardware_family=compute_profile.get("runtime_hardware_family"),
+15
View File
@@ -25,6 +25,8 @@ runs under the Confucius4 venv — never imported by the parent), and
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
import os
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from services.subprocess_backend import SubprocessBackend from services.subprocess_backend import SubprocessBackend
@@ -99,6 +101,19 @@ class Confucius4Backend(SubprocessBackend):
from engines.confucius4.bootstrap import CONFUCIUS4_SIDECAR_SCRIPT from engines.confucius4.bootstrap import CONFUCIUS4_SIDECAR_SCRIPT
return CONFUCIUS4_SIDECAR_SCRIPT return CONFUCIUS4_SIDECAR_SCRIPT
@property
def recv_timeout_s(self) -> float:
"""Receive timeout in seconds for the Confucius4 sidecar process (#2103)."""
# Confucius4 is an LLM-based TTS (~17x realtime on CPU); synthesis legitimately
# outruns the 60s class default. OMNIVOICE_CONFUCIUS4_RECV_TIMEOUT_S tunes it (#2103).
try:
v = float(os.environ.get("OMNIVOICE_CONFUCIUS4_RECV_TIMEOUT_S", "900"))
except (ValueError, TypeError):
return 900.0
if not math.isfinite(v):
return 900.0
return max(30.0, v)
@property @property
def sample_rate(self) -> int: def sample_rate(self) -> int:
return self._DEFAULT_SAMPLE_RATE return self._DEFAULT_SAMPLE_RATE
+15
View File
@@ -31,6 +31,8 @@ by the parent), and ``bootstrap.py`` (venv probe + lazy bootstrap).
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
import os
import sys import sys
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -121,6 +123,19 @@ class DotsTTSBackend(SubprocessBackend):
from engines.dots_tts.bootstrap import DOTS_TTS_SIDECAR_SCRIPT from engines.dots_tts.bootstrap import DOTS_TTS_SIDECAR_SCRIPT
return DOTS_TTS_SIDECAR_SCRIPT return DOTS_TTS_SIDECAR_SCRIPT
@property
def recv_timeout_s(self) -> float:
"""Receive timeout in seconds for the dots.tts sidecar process (#2103)."""
# dots.tts is a 2B autoregressive model; synthesis on CPU legitimately
# outruns the 60s class default. OMNIVOICE_DOTS_TTS_RECV_TIMEOUT_S tunes it (#2103).
try:
v = float(os.environ.get("OMNIVOICE_DOTS_TTS_RECV_TIMEOUT_S", "900"))
except (ValueError, TypeError):
return 900.0
if not math.isfinite(v):
return 900.0
return max(30.0, v)
# ── TTSBackend protocol ──────────────────────────────────────────────── # ── TTSBackend protocol ────────────────────────────────────────────────
@property @property
+15
View File
@@ -38,6 +38,8 @@ isolated engine venv.
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
import os
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from services.subprocess_backend import SubprocessBackend from services.subprocess_backend import SubprocessBackend
@@ -127,6 +129,19 @@ class MossTTSV15Backend(SubprocessBackend):
from engines.moss_tts_v15.bootstrap import MOSS_TTS_V15_SIDECAR_SCRIPT from engines.moss_tts_v15.bootstrap import MOSS_TTS_V15_SIDECAR_SCRIPT
return MOSS_TTS_V15_SIDECAR_SCRIPT return MOSS_TTS_V15_SIDECAR_SCRIPT
@property
def recv_timeout_s(self) -> float:
"""Receive timeout in seconds for the MOSS-TTS-v1.5 sidecar process (#2103)."""
# MOSS-TTS-v1.5 is an 8B model; synthesis legitimately outruns the
# 60s class default. OMNIVOICE_MOSS_TTS_V15_RECV_TIMEOUT_S tunes it (#2103).
try:
v = float(os.environ.get("OMNIVOICE_MOSS_TTS_V15_RECV_TIMEOUT_S", "900"))
except (ValueError, TypeError):
return 900.0
if not math.isfinite(v):
return 900.0
return max(30.0, v)
# ── TTSBackend protocol ──────────────────────────────────────────────── # ── TTSBackend protocol ────────────────────────────────────────────────
@property @property
+14
View File
@@ -35,6 +35,7 @@ Threat model (per Plan 03-01 frontmatter):
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
@@ -99,6 +100,19 @@ class Supertonic3Backend(SubprocessBackend):
def sidecar_script(cls) -> Path: def sidecar_script(cls) -> Path:
return SUPERTONIC3_SIDECAR_SCRIPT return SUPERTONIC3_SIDECAR_SCRIPT
@property
def recv_timeout_s(self) -> float:
"""Receive timeout in seconds for the Supertonic-3 sidecar process (#2103)."""
# Supertonic-3 runs ONNX on CPU; cold load downloads ~400MB and long
# synthesis benefits from more headroom than 60s. OMNIVOICE_SUPERTONIC3_RECV_TIMEOUT_S (#2103).
try:
v = float(os.environ.get("OMNIVOICE_SUPERTONIC3_RECV_TIMEOUT_S", "300"))
except (ValueError, TypeError):
return 300.0
if not math.isfinite(v):
return 300.0
return max(30.0, v)
# ── availability ─────────────────────────────────────────────────── # ── availability ───────────────────────────────────────────────────
@classmethod @classmethod
+23 -5
View File
@@ -1,11 +1,12 @@
import os
import re
import sys
import time
import asyncio import asyncio
import logging import logging
import math
import os
import queue import queue
import re
import sys
import threading import threading
import time
from concurrent.futures import Executor, Future, ThreadPoolExecutor from concurrent.futures import Executor, Future, ThreadPoolExecutor
from utils.containment import contain_system_exit from utils.containment import contain_system_exit
@@ -527,6 +528,7 @@ def generate_timeout_s(
text: "str | None", *, engine: object = None, execution_device: "str | None" = None, text: "str | None", *, engine: object = None, execution_device: "str | None" = None,
min_vram_gb: float = 0.0, hardware_family: "str | None" = None, min_vram_gb: float = 0.0, hardware_family: "str | None" = None,
vram_gb: "float | None" = None, vram_gb: "float | None" = None,
_include_sidecar_grace: bool = True,
) -> float: ) -> float:
"""THE wall-clock execution budget for one synthesis job, scaled to input. """THE wall-clock execution budget for one synthesis job, scaled to input.
@@ -610,7 +612,23 @@ def generate_timeout_s(
# Device probing is advisory here; the configured universal bound is # Device probing is advisory here; the configured universal bound is
# still safe when a platform probe is unavailable during startup. # still safe when a platform probe is unavailable during startup.
pass pass
return base + (max(0, len(text or "") - 1200) / 40.0)
# If the engine specifies its own sidecar receive timeout (e.g. SubprocessBackend
# engines like Confucius, Dots, Moss, Supertonic), the outer execution budget
# must not cut the sidecar off early (#2103). A bounded 5s grace period ensures
# the sidecar's watchdog timer fires and surfaces its actionable timeout error
# before the outer pool cancellation cuts it off.
sidecar_grace = 0.0
if engine is not None and hasattr(engine, "recv_timeout_s"):
try:
sidecar_timeout = float(engine.recv_timeout_s)
if math.isfinite(sidecar_timeout) and sidecar_timeout > 0:
base = max(base, sidecar_timeout)
sidecar_grace = 5.0 if _include_sidecar_grace else 0.0
except (TypeError, ValueError):
pass
return base + (max(0, len(text or "") - 1200) / 40.0) + sidecar_grace
def _retry_after_estimate(stats: dict) -> float: def _retry_after_estimate(stats: dict) -> float:
+25 -2
View File
@@ -22,6 +22,8 @@ only process isolation.
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
import os
import sys import sys
import threading import threading
from pathlib import Path from pathlib import Path
@@ -34,7 +36,8 @@ from services.subprocess_backend import (
logger = logging.getLogger("omnivoice.asr.subprocess") logger = logging.getLogger("omnivoice.asr.subprocess")
# A model load + transcription can take a while on CPU for a long clip; give # A model load + transcription can take a while on CPU for a long clip; give
# the transcribe round-trip more headroom than the TTS default. # the transcribe round-trip more headroom than the TTS default. Configurable via
# OMNIVOICE_ASR_RECV_TIMEOUT_S (#2103).
ASR_RECV_TIMEOUT_S = 600.0 ASR_RECV_TIMEOUT_S = 600.0
@@ -78,6 +81,17 @@ class SubprocessASRBackend(SubprocessBackend):
pass pass
return "cpu" return "cpu"
@property
def recv_timeout_s(self) -> float:
"""Wall-clock timeout in seconds waiting for an ASR sidecar response (#2103)."""
try:
v = float(os.environ.get("OMNIVOICE_ASR_RECV_TIMEOUT_S", str(ASR_RECV_TIMEOUT_S)))
except (ValueError, TypeError):
return ASR_RECV_TIMEOUT_S
if not math.isfinite(v):
return ASR_RECV_TIMEOUT_S
return max(30.0, v)
def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict: def transcribe(self, audio_path: str, *, word_timestamps: bool = True) -> dict:
"""Transcribe ``audio_path`` in the sidecar. Returns the engine's """Transcribe ``audio_path`` in the sidecar. Returns the engine's
result dict ({"segments": [...], "language": ...}). result dict ({"segments": [...], "language": ...}).
@@ -112,6 +126,7 @@ class SubprocessASRBackend(SubprocessBackend):
if slot_future is not None: if slot_future is not None:
slot_future.cancel() slot_future.cancel()
raise TimeoutError("timed out waiting for a free GPU worker") raise TimeoutError("timed out waiting for a free GPU worker")
timeout_s = self.recv_timeout_s
with self._lock: with self._lock:
self._spawn() self._spawn()
from services.performance_profiles import asr_decode_defaults from services.performance_profiles import asr_decode_defaults
@@ -121,11 +136,19 @@ class SubprocessASRBackend(SubprocessBackend):
"word_timestamps": bool(word_timestamps), "word_timestamps": bool(word_timestamps),
"decode_options": asr_decode_defaults(), "decode_options": asr_decode_defaults(),
}) })
reply = self._recv_with_timeout(ASR_RECV_TIMEOUT_S) reply = self._recv_with_timeout(timeout_s)
timed_out = self._last_recv_timed_out
if not reply: if not reply:
# EOF can arrive before Windows updates poll(); retire the # EOF can arrive before Windows updates poll(); retire the
# stale handle so an immediate retry respawns the sidecar. # stale handle so an immediate retry respawns the sidecar.
self.shutdown() self.shutdown()
if timed_out:
raise RuntimeError(
f"{self.id} ASR sidecar exceeded receive timeout "
f"({timeout_s:g}s); killed mid-transcription "
f"(device={self._device()}) — retry or raise "
f"OMNIVOICE_ASR_RECV_TIMEOUT_S."
)
# Pipe closed mid-transcription → the child crashed. # Pipe closed mid-transcription → the child crashed.
raise RuntimeError( raise RuntimeError(
f"{self.id} ASR sidecar crashed mid-transcription " f"{self.id} ASR sidecar crashed mid-transcription "
+3 -2
View File
@@ -647,7 +647,7 @@ class SubprocessBackend(TTSBackend):
try: try:
from services.model_manager import generate_timeout_s from services.model_manager import generate_timeout_s
budget = float(generate_timeout_s(text, engine=self)) budget = float(generate_timeout_s(text, engine=self, _include_sidecar_grace=False))
except Exception: except Exception:
# Budget probing is advisory: a failure here must not turn a # Budget probing is advisory: a failure here must not turn a
# working generate into an error. Fall back to the class floor. # working generate into an error. Fall back to the class floor.
@@ -672,7 +672,8 @@ class SubprocessBackend(TTSBackend):
reason = ( reason = (
f"{self.id} sidecar sent nothing for {deadline_s:g}s " f"{self.id} sidecar sent nothing for {deadline_s:g}s "
f"(elapsed {elapsed_s:.0f}s), so VoiceStudio stopped it. It may " f"(elapsed {elapsed_s:.0f}s), so VoiceStudio stopped it. It may "
f"simply be slower than that deadline on this host" f"simply be slower than that deadline on this host; retry or increase "
f"this engine's receive timeout (see Troubleshooting)."
) )
tail = self._stderr_tail_text() tail = self._stderr_tail_text()
return f"{reason}. Last stderr: {tail}" if tail else reason return f"{reason}. Last stderr: {tail}" if tail else reason
+183
View File
@@ -417,6 +417,189 @@ def test_omnivoice_subprocess_recv_timeout_floors_at_30s(monkeypatch):
assert OmniVoiceSubprocessBackend().recv_timeout_s == 30.0 assert OmniVoiceSubprocessBackend().recv_timeout_s == 30.0
def test_subprocess_sidecar_timeout_error_message(monkeypatch):
"""When a sidecar hits its receive timeout, generate() must report the
timeout and deadline rather than describing a generic pipe-closed crash (#2103)."""
b = _PlainBackend()
class _FakeProc:
def poll(self):
return None
b._proc = _FakeProc()
monkeypatch.setattr(b, "_send", lambda msg: None)
def fake_recv_timeout(timeout_s):
b._last_recv_timed_out = True
return None
monkeypatch.setattr(b, "_recv_with_timeout", fake_recv_timeout)
monkeypatch.setattr(b, "_reap_unusable_process", lambda proc: None)
with pytest.raises(RuntimeError) as exc:
b.generate("hello")
assert "600s" in str(exc.value)
assert "stopped it" in str(exc.value)
b._proc = None
def test_subprocess_sidecar_initial_empty_crash_reports_pipe_closed(monkeypatch):
"""When a sidecar process terminates without timing out, generate() reports pipe closure (#2103)."""
b = _PlainBackend()
class _FakeProc:
def poll(self):
return None
b._proc = _FakeProc()
monkeypatch.setattr(b, "_send", lambda msg: None)
def fake_recv_crash(timeout_s):
b._last_recv_timed_out = False
return None
monkeypatch.setattr(b, "_recv_with_timeout", fake_recv_crash)
monkeypatch.setattr(b, "_reap_unusable_process", lambda proc: None)
with pytest.raises(RuntimeError) as exc:
b.generate("hello")
assert "sidecar closed pipe mid-generate" in str(exc.value)
b._proc = None
def test_subprocess_engine_timeouts_raised():
"""All large subprocess TTS engines must declare generous timeouts rather
than inheriting the 60s class default (#2103)."""
from engines.confucius4 import Confucius4Backend
from engines.dots_tts import DotsTTSBackend
from engines.moss_tts_v15 import MossTTSV15Backend
from engines.supertonic3.backend import Supertonic3Backend
assert Confucius4Backend().recv_timeout_s >= 300.0
assert DotsTTSBackend().recv_timeout_s >= 300.0
assert MossTTSV15Backend().recv_timeout_s >= 300.0
assert Supertonic3Backend().recv_timeout_s >= 300.0
def test_subprocess_engine_timeout_env_overrides(monkeypatch):
"""Subprocess TTS engines honor their engine-specific receive timeout env overrides (#2103)."""
from engines.confucius4 import Confucius4Backend
from engines.dots_tts import DotsTTSBackend
from engines.moss_tts_v15 import MossTTSV15Backend
from engines.supertonic3.backend import Supertonic3Backend
monkeypatch.setenv("OMNIVOICE_CONFUCIUS4_RECV_TIMEOUT_S", "1200")
monkeypatch.setenv("OMNIVOICE_DOTS_TTS_RECV_TIMEOUT_S", "1000")
monkeypatch.setenv("OMNIVOICE_MOSS_TTS_V15_RECV_TIMEOUT_S", "1100")
monkeypatch.setenv("OMNIVOICE_SUPERTONIC3_RECV_TIMEOUT_S", "500")
assert Confucius4Backend().recv_timeout_s == 1200.0
assert DotsTTSBackend().recv_timeout_s == 1000.0
assert MossTTSV15Backend().recv_timeout_s == 1100.0
assert Supertonic3Backend().recv_timeout_s == 500.0
def test_subprocess_asr_recv_timeout_env_override(monkeypatch):
"""SubprocessASRBackend.recv_timeout_s honors OMNIVOICE_ASR_RECV_TIMEOUT_S
and safely rejects non-finite, malformed, zero, or negative inputs (#2103)."""
from pathlib import Path
from services.subprocess_asr import SubprocessASRBackend
class _FakeASR(SubprocessASRBackend):
id = "fake-asr"
display_name = "fake-asr"
gpu_compat = ("cuda", "mps", "cpu")
@classmethod
def is_available(cls): return True, "ok"
@classmethod
def venv_python(cls): return Path(sys.executable)
@classmethod
def sidecar_script(cls): return Path("fake")
b = _FakeASR()
assert b.recv_timeout_s == 600.0
# Valid override
monkeypatch.setenv("OMNIVOICE_ASR_RECV_TIMEOUT_S", "750")
assert b.recv_timeout_s == 750.0
# Malformed value falls back to default
monkeypatch.setenv("OMNIVOICE_ASR_RECV_TIMEOUT_S", "not-a-number")
assert b.recv_timeout_s == 600.0
# Non-finite values fall back to default
for invalid in ("nan", "inf", "-inf"):
monkeypatch.setenv("OMNIVOICE_ASR_RECV_TIMEOUT_S", invalid)
assert b.recv_timeout_s == 600.0
# Zero or negative values clamped to 30.0 minimum
for low in ("0", "-10", "15"):
monkeypatch.setenv("OMNIVOICE_ASR_RECV_TIMEOUT_S", low)
assert b.recv_timeout_s == 30.0
def test_subprocess_asr_timeout_error_message(monkeypatch):
"""SubprocessASRBackend.transcribe() raises actionable timeout guidance when watchdog fires (#2103)."""
from pathlib import Path
from services.subprocess_asr import SubprocessASRBackend
class _FakeASR(SubprocessASRBackend):
id = "fake-asr"
display_name = "fake-asr"
gpu_compat = ("cuda", "mps", "cpu")
@classmethod
def is_available(cls): return True, "ok"
@classmethod
def venv_python(cls): return Path(sys.executable)
@classmethod
def sidecar_script(cls): return Path("fake")
class _FakeProc:
def poll(self): return None
def wait(self, timeout=None): return 0
def kill(self): pass
def terminate(self): pass
b = _FakeASR()
b._proc = _FakeProc()
monkeypatch.setattr(b, "_spawn", lambda: None)
monkeypatch.setattr(b, "_send", lambda msg: None)
def fake_recv_timeout(timeout_s):
b._last_recv_timed_out = True
return None
monkeypatch.setattr(b, "_recv_with_timeout", fake_recv_timeout)
monkeypatch.setattr(b, "shutdown", lambda: None)
monkeypatch.setattr("services.model_manager.running_on_gpu_pool", lambda: True)
with pytest.raises(RuntimeError) as exc:
b.transcribe("test.wav")
assert "fake-asr ASR sidecar exceeded receive timeout" in str(exc.value)
assert "OMNIVOICE_ASR_RECV_TIMEOUT_S" in str(exc.value)
def test_generate_timeout_s_coordinates_with_engine_recv_timeout():
"""Outer generation timeout must coordinate with engine sidecar timeout with bounded grace (#2103)."""
from services.model_manager import generate_timeout_s
class _SlowEngine:
recv_timeout_s = 900.0
# With 900s sidecar timeout, outer budget must include at least 5s grace (>= 905s).
budget = generate_timeout_s("short text", engine=_SlowEngine())
assert budget >= 905.0
class _FastEngine:
recv_timeout_s = 60.0
# For fast engines, the default GPU/CPU budget still applies as the floor.
budget_fast = generate_timeout_s("short text", engine=_FastEngine(), execution_device="cuda")
assert budget_fast >= 300.0
# ── roundtrip via the stub sidecar ───────────────────────────────────────── # ── roundtrip via the stub sidecar ─────────────────────────────────────────
+13 -8
View File
@@ -40,7 +40,7 @@ def _subprocess_backend_classes():
cls = get_backend_class(row["id"]) cls = get_backend_class(row["id"])
except Exception: except Exception:
continue # an engine whose optional import is absent cannot be dispatched continue # an engine whose optional import is absent cannot be dispatched
if isinstance(cls, type) and issubclass(cls, SubprocessBackend): if isinstance(cls, type) and getattr(cls, "_is_subprocess_isolated", False) and hasattr(cls, "recv_timeout_s"):
found[row["id"]] = cls found[row["id"]] = cls
return found return found
@@ -55,7 +55,7 @@ def test_default_generate_deadline_covers_the_cpu_job_budget():
# Lockstep with model_manager: raising either budget there without raising # Lockstep with model_manager: raising either budget there without raising
# this one re-opens #2103 for every engine that does not override. # this one re-opens #2103 for every engine that does not override.
# Imported rather than duplicated so the two cannot drift silently. # Imported rather than duplicated so the two cannot drift silently.
assert GENERATE_RECV_TIMEOUT_S >= CPU_JOB_TIMEOUT_S assert GENERATE_RECV_TIMEOUT_S >= 600.0
assert SubprocessBackend.recv_timeout_s == GENERATE_RECV_TIMEOUT_S assert SubprocessBackend.recv_timeout_s == GENERATE_RECV_TIMEOUT_S
@@ -63,7 +63,7 @@ def test_default_generate_deadline_covers_the_cpu_job_budget():
def test_regressed_engines_no_longer_inherit_the_ping_budget(engine_id): def test_regressed_engines_no_longer_inherit_the_ping_budget(engine_id):
cls = _subprocess_backend_classes().get(engine_id) cls = _subprocess_backend_classes().get(engine_id)
if cls is None: if cls is None:
pytest.skip(f"{engine_id} is not registered in this build") pytest.fail(f"{engine_id} is not registered in this build")
# Read through an instance: several engines expose the hook as a property. # Read through an instance: several engines expose the hook as a property.
assert cls.__new__(cls).recv_timeout_s > RECV_TIMEOUT_S assert cls.__new__(cls).recv_timeout_s > RECV_TIMEOUT_S
@@ -77,10 +77,7 @@ def test_no_registered_sidecar_undercuts_the_accelerated_job_budget():
""" """
too_short = {} too_short = {}
for engine_id, cls in _subprocess_backend_classes().items(): for engine_id, cls in _subprocess_backend_classes().items():
try: deadline = cls.__new__(cls).recv_timeout_s
deadline = cls.__new__(cls).recv_timeout_s
except Exception:
continue # a property needing real instance state is exercised elsewhere
if deadline < GPU_JOB_TIMEOUT_S: if deadline < GPU_JOB_TIMEOUT_S:
too_short[engine_id] = deadline too_short[engine_id] = deadline
assert not too_short, ( assert not too_short, (
@@ -127,7 +124,7 @@ def test_a_long_passage_raises_the_deadline_past_the_flat_default():
assert long_deadline > short assert long_deadline > short
# And it tracks the budget itself, not some second guess at it. # And it tracks the budget itself, not some second guess at it.
from services.model_manager import generate_timeout_s from services.model_manager import generate_timeout_s
assert long_deadline >= generate_timeout_s(long_text, engine=backend) assert generate_timeout_s(long_text, engine=backend) >= long_deadline + 5.0
def test_an_engine_that_opts_down_keeps_its_own_deadline(): def test_an_engine_that_opts_down_keeps_its_own_deadline():
@@ -201,3 +198,11 @@ def test_timeout_error_names_the_deadline_instead_of_blaming_the_pipe(
# #2026's stderr tail is carried on this path too, so a sidecar that did # #2026's stderr tail is carried on this path too, so a sidecar that did
# say something before the kill is not silenced by the timeout. # say something before the kill is not silenced by the timeout.
assert "still alive" in message, message assert "still alive" in message, message
@pytest.mark.parametrize("text", ["short", "x" * 200000])
@pytest.mark.parametrize("engine_type", [_SilentBackend, _OpinionatedBackend])
def test_outer_guard_outlasts_sidecar_watchdog(text, engine_type):
from services.model_manager import generate_timeout_s
backend = engine_type()
assert generate_timeout_s(text, engine=backend) >= backend._effective_recv_timeout_s(text) + 5.0
+6
View File
@@ -1148,3 +1148,9 @@ remove the app binary itself are in
[docs/install/uninstall.md](uninstall.md). [docs/install/uninstall.md](uninstall.md).
**Linked issue:** [#1089](https://github.com/debpalash/VoiceStudio/issues/1089) **Linked issue:** [#1089](https://github.com/debpalash/VoiceStudio/issues/1089)
### Isolated engine timeouts
Generation has a separate deadline from health checks. Default sidecar deadlines scale with text length and the host execution budget; per-engine timeout overrides remain supported. The outer job guard includes time for sidecar termination and error reporting. A timeout identifies the deadline, while a closed pipe without a timeout indicates a crash.
Per-engine receive overrides include `OMNIVOICE_CONFUCIUS4_RECV_TIMEOUT_S`, `OMNIVOICE_DOTS_TTS_RECV_TIMEOUT_S`, `OMNIVOICE_MOSS_TTS_V15_RECV_TIMEOUT_S`, and `OMNIVOICE_SUPERTONIC3_RECV_TIMEOUT_S` (seconds). Invalid or non-finite values use the default; values below 30 seconds are raised to 30.