Files
VoiceStudio/tests/test_generate_timeout_730.py
58b023ff16 fix(diagnostics): TTS generate timeout message tells you to Flush/Unload (#939) (#999)
The guard itself (#851/#896) is working as designed — this is a message
gap, not a logic bug. The GPU-branch timeout guidance explained VRAM
contention but never mentioned the Flush/Unload action that actually
resolves it, even though: (a) that action already exists (POST
/system/flush-memory, wired to the header's Flush button), and (b) the
sibling ASR-timeout guard's message already recommends it verbatim
(asr_backend.py's _CUDA_VRAM_BUDGET_GB guidance). The maintainer ended up
manually explaining 'Settings → Models → Flush caches / Unload' in an
issue thread reply — information the error message should have carried
itself.

String-only change, no control-flow touched, mirrors the exact precedent
of #896 (a guidance-only change to this same function).

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 05:18:00 +05:30

158 lines
5.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Regression (#730 class; residual #850/#802/#755): a wedged GPU **generate**
must not brick the backend.
Before this fix, the TTS generate paths (`generation.py`, `tts_stream.py`)
dispatched to the GPU pool with no wall-clock bound and no recovery — unlike
ASR/dub/model-load, which already bound+reset on hang. On the 1–2 worker pools
we ship, one wedged generate (a Windows+CUDA hang) occupied its worker forever,
starving every other request so the next user action surfaced as the misleading
"Can't reach the local backend" even though the process was alive.
`run_on_gpu_pool_guarded` gives every GPU dispatch the same bound+reset recovery:
on timeout it abandons the wedged worker (pool `reset()`), restoring capacity,
and raises `GpuJobTimeoutError` with an actionable message.
"""
from __future__ import annotations
import asyncio
import sys
import threading
import pytest
@pytest.fixture
def model_manager(monkeypatch):
for mod_name in ("core.config", "services.model_manager"):
if getattr(sys.modules.get(mod_name), "__file__", None) is None:
sys.modules.pop(mod_name, None)
import services.model_manager as mm
return mm
def test_guard_times_out_resets_pool_and_restores_capacity(model_manager):
mm = model_manager
pool = mm._ResilientGpuPool()
release = threading.Event()
def _hang(): # a wedged generate that never returns on its own
release.wait(2.0)
return "late"
try:
# Force the inner pool to exist so we can prove reset() drops it.
assert pool._live_pool() is not None
assert pool._pool is not None
with pytest.raises(mm.GpuJobTimeoutError, match="abandoned"):
asyncio.run(
mm.run_on_gpu_pool_guarded(_hang, what="TTS generate",
timeout=0.2, executor=pool)
)
# The wedged worker was abandoned: the inner pool is dropped so the next
# submit builds a fresh one instead of queueing behind the hang.
assert pool._pool is None
# Capacity is genuinely restored — a follow-up job runs on a new worker
# even while the orphaned one is still stuck.
result = asyncio.run(
mm.run_on_gpu_pool_guarded(lambda: "ok", what="TTS generate",
timeout=5.0, executor=pool)
)
assert result == "ok"
finally:
release.set() # let the orphaned worker exit immediately
def test_guard_happy_path_returns_value(model_manager):
mm = model_manager
pool = mm._ResilientGpuPool()
try:
result = asyncio.run(
mm.run_on_gpu_pool_guarded(lambda: 42, what="TTS generate",
timeout=5.0, executor=pool)
)
assert result == 42
finally:
pool.shutdown(wait=False)
def test_guard_timeout_env_default(model_manager, monkeypatch):
"""The generate bound is env-overridable (parity with the ASR bound)."""
import importlib
monkeypatch.setenv("OMNIVOICE_GENERATE_TIMEOUT_S", "123.5")
mm = importlib.reload(model_manager)
try:
assert mm.GPU_JOB_TIMEOUT_S == 123.5
finally:
monkeypatch.delenv("OMNIVOICE_GENERATE_TIMEOUT_S", raising=False)
importlib.reload(mm)
def test_guard_without_reset_still_bounds(model_manager):
"""A plain executor (no `reset`, e.g. in other call sites/tests) still gets
the wall-clock bound + actionable error — reset is best-effort, not required.
"""
from concurrent.futures import ThreadPoolExecutor
mm = model_manager
ex = ThreadPoolExecutor(max_workers=1)
release = threading.Event()
def _hang():
release.wait(2.0)
try:
with pytest.raises(mm.GpuJobTimeoutError):
asyncio.run(
mm.run_on_gpu_pool_guarded(_hang, timeout=0.2, executor=ex)
)
finally:
release.set()
ex.shutdown(wait=False)
# ── Device-aware timeout guidance (#896) ────────────────────────────────────
# A CPU-only host was told "the GPU is VRAM-starved … set the engine to CPU
# in Settings" — nonsense when the resolved device already IS cpu.
def _guidance_for(monkeypatch, family):
import types
from services import model_manager as mm
import core.device_caps as caps
monkeypatch.setattr(
caps, "detect_host_caps", lambda: types.SimpleNamespace(family=family)
)
return mm._timeout_guidance("TTS generate", 300.0)
def test_cpu_host_gets_compute_bound_guidance(monkeypatch):
msg = _guidance_for(monkeypatch, "cpu")
assert "VRAM" not in msg
assert "set the engine to CPU" not in msg
assert "compute-bound" in msg
assert "OMNIVOICE_GENERATE_TIMEOUT_S" in msg
def test_gpu_host_keeps_vram_guidance(monkeypatch):
msg = _guidance_for(monkeypatch, "cuda")
assert "VRAM-starved" in msg
assert "set the engine to CPU" in msg
# #939: the sibling ASR timeout guard already recommends Flush/Unload —
# this message was missing it, forcing the maintainer to explain it
# manually in every report instead of the error saying so upfront.
assert "Flush" in msg
def test_probe_failure_defaults_to_gpu_wording(monkeypatch):
import core.device_caps as caps
from services import model_manager as mm
def _boom():
raise RuntimeError("probe failed")
monkeypatch.setattr(caps, "detect_host_caps", _boom)
msg = mm._timeout_guidance("TTS generate", 300.0)
assert "VRAM-starved" in msg # conservative default, never crashes