fix(sidecars): reconcile receive deadlines with outer job budgets
This commit is contained in:
+4
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 "
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user